mirror of
https://github.com/IBM/fp-go.git
synced 2026-01-13 00:44:11 +02:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f154790d88 | ||
|
|
e010f13dce | ||
|
|
86a260a204 | ||
|
|
6a6b982779 | ||
|
|
9d31752887 | ||
|
|
14b52568b5 | ||
|
|
49227551b6 | ||
|
|
69691e9e70 | ||
|
|
d3c466bfb7 | ||
|
|
a6c6ea804f | ||
|
|
31ff98901e | ||
|
|
255cf4353c | ||
|
|
4dfc1b5a44 | ||
|
|
20398e67a9 | ||
|
|
fceda15701 | ||
|
|
4ebfcadabe | ||
|
|
acb601fc01 | ||
|
|
d17663f016 | ||
|
|
829365fc24 | ||
|
|
64b5660b4e | ||
|
|
16e82d6a65 | ||
|
|
0d40fdcebb | ||
|
|
6a4dfa2c93 | ||
|
|
a37f379a3c | ||
|
|
ece0cd135d |
@@ -369,6 +369,11 @@ func ToIOOption[GA ~func() O.Option[A], GEA ~func() ET.Either[E, A], E, A any](i
|
||||
)
|
||||
}
|
||||
|
||||
// OrElse returns the original IOEither if it is a Right, otherwise it applies the given function to the error and returns the result.
|
||||
func OrElse[GA ~func() ET.Either[E, A], E, A any](onLeft func(E) GA) func(GA) GA {
|
||||
return eithert.OrElse(IO.MonadChain[GA, GA, ET.Either[E, A], ET.Either[E, A]], IO.Of[GA, ET.Either[E, A]], onLeft)
|
||||
}
|
||||
|
||||
func FromIOOption[GEA ~func() ET.Either[E, A], GA ~func() O.Option[A], E, A any](onNone func() E) func(ioo GA) GEA {
|
||||
return IO.Map[GA, GEA](ET.FromOption[A](onNone))
|
||||
}
|
||||
|
||||
@@ -266,6 +266,11 @@ func Alt[E, A any](second L.Lazy[IOEither[E, A]]) func(IOEither[E, A]) IOEither[
|
||||
return G.Alt(second)
|
||||
}
|
||||
|
||||
// OrElse returns the original IOEither if it is a Right, otherwise it applies the given function to the error and returns the result.
|
||||
func OrElse[E, A any](onLeft func(E) IOEither[E, A]) func(IOEither[E, A]) IOEither[E, A] {
|
||||
return G.OrElse[IOEither[E, A]](onLeft)
|
||||
}
|
||||
|
||||
func MonadFlap[E, B, A any](fab IOEither[E, func(A) B], a A) IOEither[E, B] {
|
||||
return G.MonadFlap[IOEither[E, func(A) B], IOEither[E, B]](fab, a)
|
||||
}
|
||||
|
||||
@@ -134,3 +134,44 @@ func TestApSecond(t *testing.T) {
|
||||
|
||||
assert.Equal(t, E.Of[error]("b"), x())
|
||||
}
|
||||
|
||||
func TestOrElse(t *testing.T) {
|
||||
// Test that OrElse recovers from a Left
|
||||
recover := OrElse(func(err string) IOEither[string, int] {
|
||||
return Right[string](42)
|
||||
})
|
||||
|
||||
// When input is Left, should recover
|
||||
leftResult := F.Pipe1(
|
||||
Left[int]("error"),
|
||||
recover,
|
||||
)
|
||||
assert.Equal(t, E.Right[string](42), leftResult())
|
||||
|
||||
// When input is Right, should pass through unchanged
|
||||
rightResult := F.Pipe1(
|
||||
Right[string](100),
|
||||
recover,
|
||||
)
|
||||
assert.Equal(t, E.Right[string](100), rightResult())
|
||||
|
||||
// Test that OrElse can also return a Left (propagate different error)
|
||||
recoverOrFail := OrElse(func(err string) IOEither[string, int] {
|
||||
if err == "recoverable" {
|
||||
return Right[string](0)
|
||||
}
|
||||
return Left[int]("unrecoverable: " + err)
|
||||
})
|
||||
|
||||
recoverable := F.Pipe1(
|
||||
Left[int]("recoverable"),
|
||||
recoverOrFail,
|
||||
)
|
||||
assert.Equal(t, E.Right[string](0), recoverable())
|
||||
|
||||
unrecoverable := F.Pipe1(
|
||||
Left[int]("fatal"),
|
||||
recoverOrFail,
|
||||
)
|
||||
assert.Equal(t, E.Left[int]("unrecoverable: fatal"), unrecoverable())
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ import (
|
||||
|
||||
// Create a pipeline of transformations
|
||||
pipeline := F.Flow3(
|
||||
A.Filter(func(x int) bool { return x > 0 }), // Keep positive numbers
|
||||
A.Filter(N.MoreThan(0)), // Keep positive numbers
|
||||
A.Map(N.Mul(2)), // Double each number
|
||||
A.Reduce(func(acc, x int) int { return acc + x }, 0), // Sum them up
|
||||
)
|
||||
|
||||
212
v2/EXAMPLE_TESTS_PROGRESS.md
Normal file
212
v2/EXAMPLE_TESTS_PROGRESS.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# Example Tests Progress
|
||||
|
||||
This document tracks the progress of converting documentation examples into executable example test files.
|
||||
|
||||
## Overview
|
||||
|
||||
The codebase has 300+ documentation examples across many packages. This document tracks which packages have been completed and which still need work.
|
||||
|
||||
## Completed Packages
|
||||
|
||||
### Core Packages
|
||||
- [x] **result** - Created `examples_bind_test.go`, `examples_curry_test.go`, `examples_apply_test.go`
|
||||
- Files: `bind.go` (10 examples), `curry.go` (5 examples), `apply.go` (2 examples)
|
||||
- Status: ✅ 17 tests passing
|
||||
|
||||
### Utility Packages
|
||||
- [x] **pair** - Created `examples_test.go`
|
||||
- Files: `pair.go` (14 examples)
|
||||
- Status: ✅ 14 tests passing
|
||||
|
||||
- [x] **tuple** - Created `examples_test.go`
|
||||
- Files: `tuple.go` (6 examples)
|
||||
- Status: ✅ 6 tests passing
|
||||
|
||||
### Type Class Packages
|
||||
- [x] **semigroup** - Created `examples_test.go`
|
||||
- Files: `semigroup.go` (7 examples)
|
||||
- Status: ✅ 7 tests passing
|
||||
|
||||
### Utility Packages (continued)
|
||||
- [x] **predicate** - Created `examples_test.go`
|
||||
- Files: `bool.go` (3 examples), `contramap.go` (1 example)
|
||||
- Status: ✅ 4 tests passing
|
||||
|
||||
### Context Reader Packages
|
||||
- [x] **idiomatic/context/readerresult** - Created `examples_reader_test.go`, `examples_bind_test.go`
|
||||
- Files: `reader.go` (8 examples), `bind.go` (14 examples)
|
||||
- Status: ✅ 22 tests passing
|
||||
|
||||
## Summary Statistics
|
||||
- **Total Example Tests Created**: 74
|
||||
- **Total Packages Completed**: 7 (result, pair, tuple, semigroup, predicate, idiomatic/context/readerresult)
|
||||
- **All Tests Status**: ✅ PASSING
|
||||
|
||||
### Breakdown by Package
|
||||
- **result**: 21 tests (bind: 10, curry: 5, apply: 2, array: 4)
|
||||
- **pair**: 14 tests
|
||||
- **tuple**: 6 tests
|
||||
- **semigroup**: 7 tests
|
||||
- **predicate**: 4 tests
|
||||
- **idiomatic/context/readerresult**: 22 tests (reader: 8, bind: 14)
|
||||
|
||||
## Packages with Existing Examples
|
||||
|
||||
These packages already have some example test files:
|
||||
- result (has `examples_create_test.go`, `examples_extract_test.go`)
|
||||
- option (has `examples_create_test.go`, `examples_extract_test.go`)
|
||||
- either (has `examples_create_test.go`, `examples_extract_test.go`)
|
||||
- ioeither (has `examples_create_test.go`, `examples_do_test.go`, `examples_extract_test.go`)
|
||||
- ioresult (has `examples_create_test.go`, `examples_do_test.go`, `examples_extract_test.go`)
|
||||
- lazy (has `example_lazy_test.go`)
|
||||
- array (has `examples_basic_test.go`, `examples_sort_test.go`, `example_any_test.go`, `example_find_test.go`)
|
||||
- readerioeither (has `traverse_example_test.go`)
|
||||
- context/readerioresult (has `flip_example_test.go`)
|
||||
|
||||
## Packages Needing Example Tests
|
||||
|
||||
### Core Packages (High Priority)
|
||||
- [ ] **result** - Additional files need examples:
|
||||
- `apply.go` (2 examples)
|
||||
- `array.go` (7 examples)
|
||||
- `core.go` (6 examples)
|
||||
- `either.go` (26 examples)
|
||||
- `eq.go` (2 examples)
|
||||
- `functor.go` (1 example)
|
||||
|
||||
- [ ] **option** - Additional files need examples
|
||||
- [ ] **either** - Additional files need examples
|
||||
|
||||
### Reader Packages (High Priority)
|
||||
- [ ] **reader** - Many examples in:
|
||||
- `array.go` (12 examples)
|
||||
- `bind.go` (10 examples)
|
||||
- `curry.go` (8 examples)
|
||||
- `flip.go` (2 examples)
|
||||
- `reader.go` (21 examples)
|
||||
|
||||
- [ ] **readeroption** - Examples in:
|
||||
- `array.go` (3 examples)
|
||||
- `bind.go` (7 examples)
|
||||
- `curry.go` (5 examples)
|
||||
- `flip.go` (2 examples)
|
||||
- `from.go` (4 examples)
|
||||
- `reader.go` (18 examples)
|
||||
- `sequence.go` (4 examples)
|
||||
|
||||
- [ ] **readerresult** - Examples in:
|
||||
- `array.go` (3 examples)
|
||||
- `bind.go` (24 examples)
|
||||
- `curry.go` (7 examples)
|
||||
- `flip.go` (2 examples)
|
||||
- `from.go` (4 examples)
|
||||
- `monoid.go` (3 examples)
|
||||
|
||||
- [ ] **readereither** - Examples in:
|
||||
- `array.go` (3 examples)
|
||||
- `bind.go` (7 examples)
|
||||
- `flip.go` (3 examples)
|
||||
|
||||
- [ ] **readerio** - Examples in:
|
||||
- `array.go` (3 examples)
|
||||
- `bind.go` (7 examples)
|
||||
- `flip.go` (2 examples)
|
||||
- `logging.go` (4 examples)
|
||||
- `reader.go` (30 examples)
|
||||
|
||||
- [ ] **readerioeither** - Examples in:
|
||||
- `bind.go` (7 examples)
|
||||
- `flip.go` (1 example)
|
||||
|
||||
- [ ] **readerioresult** - Examples in:
|
||||
- `array.go` (8 examples)
|
||||
- `bind.go` (24 examples)
|
||||
|
||||
### State Packages
|
||||
- [ ] **statereaderioeither** - Examples in:
|
||||
- `bind.go` (5 examples)
|
||||
- `resource.go` (1 example)
|
||||
- `state.go` (13 examples)
|
||||
|
||||
### Utility Packages
|
||||
- [ ] **lazy** - Additional examples in:
|
||||
- `apply.go` (2 examples)
|
||||
- `bind.go` (7 examples)
|
||||
- `lazy.go` (10 examples)
|
||||
- `sequence.go` (4 examples)
|
||||
- `traverse.go` (2 examples)
|
||||
|
||||
- [ ] **pair** - Additional examples in:
|
||||
- `monad.go` (12 examples)
|
||||
- `pair.go` (remaining ~20 examples)
|
||||
|
||||
- [ ] **tuple** - Examples in:
|
||||
- `tuple.go` (6 examples)
|
||||
|
||||
- [ ] **predicate** - Examples in:
|
||||
- `bool.go` (3 examples)
|
||||
- `contramap.go` (1 example)
|
||||
- `monoid.go` (4 examples)
|
||||
|
||||
- [ ] **retry** - Examples in:
|
||||
- `retry.go` (7 examples)
|
||||
|
||||
- [ ] **logging** - Examples in:
|
||||
- `logger.go` (5 examples)
|
||||
|
||||
### Collection Packages
|
||||
- [ ] **record** - Examples in:
|
||||
- `bind.go` (3 examples)
|
||||
|
||||
### Type Class Packages
|
||||
- [ ] **semigroup** - Examples in:
|
||||
- `alt.go` (1 example)
|
||||
- `apply.go` (1 example)
|
||||
- `array.go` (4 examples)
|
||||
- `semigroup.go` (7 examples)
|
||||
|
||||
- [ ] **ord** - Examples in:
|
||||
- `ord.go` (1 example)
|
||||
|
||||
## Strategy for Completion
|
||||
|
||||
1. **Prioritize by usage**: Focus on core packages (result, option, either) first
|
||||
2. **Group by package**: Complete all examples for one package before moving to next
|
||||
3. **Test incrementally**: Run tests after each file to catch errors early
|
||||
4. **Follow patterns**: Use existing example test files as templates
|
||||
5. **Document as you go**: Update this file with progress
|
||||
|
||||
## Example Test File Template
|
||||
|
||||
```go
|
||||
// Copyright header...
|
||||
|
||||
package packagename_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
PKG "github.com/IBM/fp-go/v2/packagename"
|
||||
)
|
||||
|
||||
func ExampleFunctionName() {
|
||||
// Copy example from doc comment
|
||||
// Ensure it compiles and produces correct output
|
||||
fmt.Println(result)
|
||||
// Output:
|
||||
// expected output
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Use `F.Constant1[error](defaultValue)` for GetOrElse in result package
|
||||
- Use `F.Pipe1` instead of `F.Pipe2` when only one transformation
|
||||
- Check function signatures carefully for type parameters
|
||||
- Some functions like `BiMap` are capitalized differently than in docs
|
||||
- **Prefer `R.Eitherize1(func)` over manual error handling** - converts `func(T) (R, error)` to `func(T) Result[R]`
|
||||
- Example: Use `R.Eitherize1(strconv.Atoi)` instead of manual if/else error checking
|
||||
- **Add Go documentation comments to all example functions** - Each example should have a comment explaining what it demonstrates
|
||||
- **Idiomatic vs Non-Idiomatic packages**:
|
||||
- Non-idiomatic (e.g., `result`): Uses `Result[A]` type (Either monad)
|
||||
- Idiomatic (e.g., `idiomatic/result`): Uses `(A, error)` tuples (Go-style)
|
||||
- Context readers use non-idiomatic `Result[A]` internally
|
||||
19
v2/README.md
19
v2/README.md
@@ -61,6 +61,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
N "github.com/IBM/fp-go/v2/number"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -145,6 +146,8 @@ func main() {
|
||||
}
|
||||
```
|
||||
|
||||
## ⚠️ Breaking Changes
|
||||
|
||||
### From V1 to V2
|
||||
|
||||
#### 1. Generic Type Aliases
|
||||
@@ -449,17 +452,27 @@ func process() IOResult[string] {
|
||||
|
||||
### Core Modules
|
||||
|
||||
#### Standard Packages (Struct-based)
|
||||
- **Option** - Represent optional values without nil
|
||||
- **Either** - Type-safe error handling with left/right values
|
||||
- **Result** - Simplified Either with error as left type
|
||||
- **Result** - Simplified Either with error as left type (recommended for error handling)
|
||||
- **IO** - Lazy evaluation and side effect management
|
||||
- **IOEither** - Combine IO with error handling
|
||||
- **IOResult** - Combine IO with Result for error handling (recommended over IOEither)
|
||||
- **Reader** - Dependency injection pattern
|
||||
- **ReaderIOEither** - Combine Reader, IO, and Either for complex workflows
|
||||
- **ReaderIOResult** - Combine Reader, IO, and Result for complex workflows
|
||||
- **Array** - Functional array operations
|
||||
- **Record** - Functional record/map operations
|
||||
- **Optics** - Lens, Prism, Optional, and Traversal for immutable updates
|
||||
|
||||
#### Idiomatic Packages (Tuple-based, High Performance)
|
||||
- **idiomatic/option** - Option monad using native Go `(value, bool)` tuples
|
||||
- **idiomatic/result** - Result monad using native Go `(value, error)` tuples
|
||||
- **idiomatic/ioresult** - IOResult monad using `func() (value, error)` for IO operations
|
||||
- **idiomatic/readerresult** - Reader monad combined with Result pattern
|
||||
- **idiomatic/readerioresult** - Reader monad combined with IOResult pattern
|
||||
|
||||
The idiomatic packages offer 2-10x performance improvements and zero allocations by using Go's native tuple patterns instead of struct wrappers. Use them for performance-critical code or when you prefer Go's native error handling style.
|
||||
|
||||
## 🤔 Should I Migrate?
|
||||
|
||||
**Migrate to V2 if:**
|
||||
|
||||
@@ -536,3 +536,89 @@ func Flap[B, A any](a A) Operator[func(A) B, B] {
|
||||
func Prepend[A any](head A) Operator[A, A] {
|
||||
return G.Prepend[Operator[A, A]](head)
|
||||
}
|
||||
|
||||
// Reverse returns a new slice with elements in reverse order.
|
||||
// This function creates a new slice containing all elements from the input slice
|
||||
// in reverse order, without modifying the original slice.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of elements in the slice
|
||||
//
|
||||
// Parameters:
|
||||
// - as: The input slice to reverse
|
||||
//
|
||||
// Returns:
|
||||
// - A new slice with elements in reverse order
|
||||
//
|
||||
// Behavior:
|
||||
// - Creates a new slice with the same length as the input
|
||||
// - Copies elements from the input slice in reverse order
|
||||
// - Does not modify the original slice
|
||||
// - Returns an empty slice if the input is empty
|
||||
// - Returns a single-element slice unchanged if input has one element
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// numbers := []int{1, 2, 3, 4, 5}
|
||||
// reversed := array.Reverse(numbers)
|
||||
// // reversed: []int{5, 4, 3, 2, 1}
|
||||
// // numbers: []int{1, 2, 3, 4, 5} (unchanged)
|
||||
//
|
||||
// Example with strings:
|
||||
//
|
||||
// words := []string{"hello", "world", "foo", "bar"}
|
||||
// reversed := array.Reverse(words)
|
||||
// // reversed: []string{"bar", "foo", "world", "hello"}
|
||||
//
|
||||
// Example with empty slice:
|
||||
//
|
||||
// empty := []int{}
|
||||
// reversed := array.Reverse(empty)
|
||||
// // reversed: []int{} (empty slice)
|
||||
//
|
||||
// Example with single element:
|
||||
//
|
||||
// single := []string{"only"}
|
||||
// reversed := array.Reverse(single)
|
||||
// // reversed: []string{"only"}
|
||||
//
|
||||
// Use cases:
|
||||
// - Reversing the order of elements for display or processing
|
||||
// - Implementing stack-like behavior (LIFO)
|
||||
// - Processing data in reverse chronological order
|
||||
// - Reversing transformation pipelines
|
||||
// - Creating palindrome checks
|
||||
// - Implementing undo/redo functionality
|
||||
//
|
||||
// Example with processing in reverse:
|
||||
//
|
||||
// events := []string{"start", "middle", "end"}
|
||||
// reversed := array.Reverse(events)
|
||||
// // Process events in reverse order
|
||||
// for _, event := range reversed {
|
||||
// fmt.Println(event) // Prints: "end", "middle", "start"
|
||||
// }
|
||||
//
|
||||
// Example with functional composition:
|
||||
//
|
||||
// numbers := []int{1, 2, 3, 4, 5}
|
||||
// result := F.Pipe2(
|
||||
// numbers,
|
||||
// array.Map(N.Mul(2)),
|
||||
// array.Reverse,
|
||||
// )
|
||||
// // result: []int{10, 8, 6, 4, 2}
|
||||
//
|
||||
// Performance:
|
||||
// - Time complexity: O(n) where n is the length of the slice
|
||||
// - Space complexity: O(n) for the new slice
|
||||
// - Does not allocate if the input slice is empty
|
||||
//
|
||||
// Note: This function is immutable - it does not modify the original slice.
|
||||
// If you need to reverse a slice in-place, consider using a different approach
|
||||
// or modifying the slice directly.
|
||||
//
|
||||
//go:inline
|
||||
func Reverse[A any](as []A) []A {
|
||||
return G.Reverse(as)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/internal/utils"
|
||||
N "github.com/IBM/fp-go/v2/number"
|
||||
O "github.com/IBM/fp-go/v2/option"
|
||||
S "github.com/IBM/fp-go/v2/string"
|
||||
T "github.com/IBM/fp-go/v2/tuple"
|
||||
@@ -214,3 +215,262 @@ func ExampleFoldMap() {
|
||||
// Output: ABC
|
||||
|
||||
}
|
||||
|
||||
// TestReverse tests the Reverse function
|
||||
func TestReverse(t *testing.T) {
|
||||
t.Run("Reverse integers", func(t *testing.T) {
|
||||
input := []int{1, 2, 3, 4, 5}
|
||||
result := Reverse(input)
|
||||
expected := []int{5, 4, 3, 2, 1}
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
|
||||
t.Run("Reverse strings", func(t *testing.T) {
|
||||
input := []string{"hello", "world", "foo", "bar"}
|
||||
result := Reverse(input)
|
||||
expected := []string{"bar", "foo", "world", "hello"}
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
|
||||
t.Run("Reverse empty slice", func(t *testing.T) {
|
||||
input := []int{}
|
||||
result := Reverse(input)
|
||||
assert.Equal(t, []int{}, result)
|
||||
})
|
||||
|
||||
t.Run("Reverse single element", func(t *testing.T) {
|
||||
input := []string{"only"}
|
||||
result := Reverse(input)
|
||||
assert.Equal(t, []string{"only"}, result)
|
||||
})
|
||||
|
||||
t.Run("Reverse two elements", func(t *testing.T) {
|
||||
input := []int{1, 2}
|
||||
result := Reverse(input)
|
||||
assert.Equal(t, []int{2, 1}, result)
|
||||
})
|
||||
|
||||
t.Run("Does not modify original slice", func(t *testing.T) {
|
||||
original := []int{1, 2, 3, 4, 5}
|
||||
originalCopy := []int{1, 2, 3, 4, 5}
|
||||
_ = Reverse(original)
|
||||
assert.Equal(t, originalCopy, original)
|
||||
})
|
||||
|
||||
t.Run("Reverse with floats", func(t *testing.T) {
|
||||
input := []float64{1.1, 2.2, 3.3}
|
||||
result := Reverse(input)
|
||||
expected := []float64{3.3, 2.2, 1.1}
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
|
||||
t.Run("Reverse with structs", func(t *testing.T) {
|
||||
type Person struct {
|
||||
Name string
|
||||
Age int
|
||||
}
|
||||
input := []Person{
|
||||
{"Alice", 30},
|
||||
{"Bob", 25},
|
||||
{"Charlie", 35},
|
||||
}
|
||||
result := Reverse(input)
|
||||
expected := []Person{
|
||||
{"Charlie", 35},
|
||||
{"Bob", 25},
|
||||
{"Alice", 30},
|
||||
}
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
|
||||
t.Run("Reverse with pointers", func(t *testing.T) {
|
||||
a, b, c := 1, 2, 3
|
||||
input := []*int{&a, &b, &c}
|
||||
result := Reverse(input)
|
||||
assert.Equal(t, []*int{&c, &b, &a}, result)
|
||||
})
|
||||
|
||||
t.Run("Double reverse returns original order", func(t *testing.T) {
|
||||
original := []int{1, 2, 3, 4, 5}
|
||||
reversed := Reverse(original)
|
||||
doubleReversed := Reverse(reversed)
|
||||
assert.Equal(t, original, doubleReversed)
|
||||
})
|
||||
|
||||
t.Run("Reverse with large slice", func(t *testing.T) {
|
||||
input := MakeBy(1000, F.Identity[int])
|
||||
result := Reverse(input)
|
||||
|
||||
// Check first and last elements
|
||||
assert.Equal(t, 999, result[0])
|
||||
assert.Equal(t, 0, result[999])
|
||||
|
||||
// Check length
|
||||
assert.Equal(t, 1000, len(result))
|
||||
})
|
||||
|
||||
t.Run("Reverse palindrome", func(t *testing.T) {
|
||||
input := []int{1, 2, 3, 2, 1}
|
||||
result := Reverse(input)
|
||||
assert.Equal(t, input, result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestReverseComposition tests Reverse with other array operations
|
||||
func TestReverseComposition(t *testing.T) {
|
||||
t.Run("Reverse after Map", func(t *testing.T) {
|
||||
input := []int{1, 2, 3, 4, 5}
|
||||
result := F.Pipe2(
|
||||
input,
|
||||
Map(N.Mul(2)),
|
||||
Reverse[int],
|
||||
)
|
||||
expected := []int{10, 8, 6, 4, 2}
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
|
||||
t.Run("Map after Reverse", func(t *testing.T) {
|
||||
input := []int{1, 2, 3, 4, 5}
|
||||
result := F.Pipe2(
|
||||
input,
|
||||
Reverse[int],
|
||||
Map(N.Mul(2)),
|
||||
)
|
||||
expected := []int{10, 8, 6, 4, 2}
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
|
||||
t.Run("Reverse with Filter", func(t *testing.T) {
|
||||
input := []int{1, 2, 3, 4, 5, 6}
|
||||
result := F.Pipe2(
|
||||
input,
|
||||
Filter(func(n int) bool { return n%2 == 0 }),
|
||||
Reverse[int],
|
||||
)
|
||||
expected := []int{6, 4, 2}
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
|
||||
t.Run("Reverse with Reduce", func(t *testing.T) {
|
||||
input := []string{"a", "b", "c"}
|
||||
reversed := Reverse(input)
|
||||
result := Reduce(func(acc, val string) string {
|
||||
return acc + val
|
||||
}, "")(reversed)
|
||||
assert.Equal(t, "cba", result)
|
||||
})
|
||||
|
||||
t.Run("Reverse with Flatten", func(t *testing.T) {
|
||||
input := [][]int{{1, 2}, {3, 4}, {5, 6}}
|
||||
result := F.Pipe2(
|
||||
input,
|
||||
Reverse[[]int],
|
||||
Flatten[int],
|
||||
)
|
||||
expected := []int{5, 6, 3, 4, 1, 2}
|
||||
assert.Equal(t, expected, result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestReverseUseCases demonstrates practical use cases for Reverse
|
||||
func TestReverseUseCases(t *testing.T) {
|
||||
t.Run("Process events in reverse chronological order", func(t *testing.T) {
|
||||
events := []string{"2024-01-01", "2024-01-02", "2024-01-03"}
|
||||
reversed := Reverse(events)
|
||||
|
||||
// Most recent first
|
||||
assert.Equal(t, "2024-01-03", reversed[0])
|
||||
assert.Equal(t, "2024-01-01", reversed[2])
|
||||
})
|
||||
|
||||
t.Run("Implement stack behavior (LIFO)", func(t *testing.T) {
|
||||
stack := []int{1, 2, 3, 4, 5}
|
||||
reversed := Reverse(stack)
|
||||
|
||||
// Pop from reversed (LIFO)
|
||||
assert.Equal(t, 5, reversed[0])
|
||||
assert.Equal(t, 4, reversed[1])
|
||||
})
|
||||
|
||||
t.Run("Reverse string characters", func(t *testing.T) {
|
||||
chars := []rune("hello")
|
||||
reversed := Reverse(chars)
|
||||
result := string(reversed)
|
||||
assert.Equal(t, "olleh", result)
|
||||
})
|
||||
|
||||
t.Run("Check palindrome", func(t *testing.T) {
|
||||
word := []rune("racecar")
|
||||
reversed := Reverse(word)
|
||||
assert.Equal(t, word, reversed)
|
||||
|
||||
notPalindrome := []rune("hello")
|
||||
reversedNot := Reverse(notPalindrome)
|
||||
assert.NotEqual(t, notPalindrome, reversedNot)
|
||||
})
|
||||
|
||||
t.Run("Reverse transformation pipeline", func(t *testing.T) {
|
||||
// Apply transformations in reverse order
|
||||
numbers := []int{1, 2, 3}
|
||||
|
||||
// Normal: add 10, then multiply by 2
|
||||
normal := F.Pipe2(
|
||||
numbers,
|
||||
Map(N.Add(10)),
|
||||
Map(N.Mul(2)),
|
||||
)
|
||||
|
||||
// Reversed order of operations
|
||||
reversed := F.Pipe2(
|
||||
numbers,
|
||||
Map(N.Mul(2)),
|
||||
Map(N.Add(10)),
|
||||
)
|
||||
|
||||
assert.NotEqual(t, normal, reversed)
|
||||
assert.Equal(t, []int{22, 24, 26}, normal)
|
||||
assert.Equal(t, []int{12, 14, 16}, reversed)
|
||||
})
|
||||
}
|
||||
|
||||
// TestReverseProperties tests mathematical properties of Reverse
|
||||
func TestReverseProperties(t *testing.T) {
|
||||
t.Run("Involution property: Reverse(Reverse(x)) == x", func(t *testing.T) {
|
||||
testCases := [][]int{
|
||||
{1, 2, 3, 4, 5},
|
||||
{1},
|
||||
{},
|
||||
{1, 2},
|
||||
{5, 4, 3, 2, 1},
|
||||
}
|
||||
|
||||
for _, original := range testCases {
|
||||
result := Reverse(Reverse(original))
|
||||
assert.Equal(t, original, result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Length preservation: len(Reverse(x)) == len(x)", func(t *testing.T) {
|
||||
testCases := [][]int{
|
||||
{1, 2, 3, 4, 5},
|
||||
{1},
|
||||
{},
|
||||
MakeBy(100, F.Identity[int]),
|
||||
}
|
||||
|
||||
for _, input := range testCases {
|
||||
result := Reverse(input)
|
||||
assert.Equal(t, len(input), len(result))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("First element becomes last", func(t *testing.T) {
|
||||
input := []int{1, 2, 3, 4, 5}
|
||||
result := Reverse(input)
|
||||
|
||||
if len(input) > 0 {
|
||||
assert.Equal(t, input[0], result[len(result)-1])
|
||||
assert.Equal(t, input[len(input)-1], result[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,22 +16,11 @@
|
||||
package array
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
E "github.com/IBM/fp-go/v2/eq"
|
||||
)
|
||||
|
||||
func equals[T any](left []T, right []T, eq func(T, T) bool) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for i, v1 := range left {
|
||||
v2 := right[i]
|
||||
if !eq(v1, v2) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Eq creates an equality checker for arrays given an equality checker for elements.
|
||||
// Two arrays are considered equal if they have the same length and all corresponding
|
||||
// elements are equal according to the provided Eq instance.
|
||||
@@ -46,6 +35,11 @@ func equals[T any](left []T, right []T, eq func(T, T) bool) bool {
|
||||
func Eq[T any](e E.Eq[T]) E.Eq[[]T] {
|
||||
eq := e.Equals
|
||||
return E.FromEquals(func(left, right []T) bool {
|
||||
return equals(left, right, eq)
|
||||
return slices.EqualFunc(left, right, eq)
|
||||
})
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func StrictEquals[T comparable]() E.Eq[[]T] {
|
||||
return E.FromEquals(slices.Equal[[]T])
|
||||
}
|
||||
|
||||
@@ -140,22 +140,27 @@ func Empty[GA ~[]A, A any]() GA {
|
||||
return array.Empty[GA]()
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func UpsertAt[GA ~[]A, A any](a A) func(GA) GA {
|
||||
return array.UpsertAt[GA](a)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func MonadMap[GA ~[]A, GB ~[]B, A, B any](as GA, f func(a A) B) GB {
|
||||
return array.MonadMap[GA, GB](as, f)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func Map[GA ~[]A, GB ~[]B, A, B any](f func(a A) B) func(GA) GB {
|
||||
return array.Map[GA, GB](f)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func MonadMapWithIndex[GA ~[]A, GB ~[]B, A, B any](as GA, f func(int, A) B) GB {
|
||||
return array.MonadMapWithIndex[GA, GB](as, f)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func MapWithIndex[GA ~[]A, GB ~[]B, A, B any](f func(int, A) B) func(GA) GB {
|
||||
return F.Bind2nd(MonadMapWithIndex[GA, GB, A, B], f)
|
||||
}
|
||||
@@ -297,7 +302,7 @@ func MatchLeft[AS ~[]A, A, B any](onEmpty func() B, onNonEmpty func(A, AS) B) fu
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func Slice[AS ~[]A, A any](start int, end int) func(AS) AS {
|
||||
func Slice[AS ~[]A, A any](start, end int) func(AS) AS {
|
||||
return array.Slice[AS](start, end)
|
||||
}
|
||||
|
||||
@@ -361,6 +366,12 @@ func Flap[FAB ~func(A) B, GFAB ~[]FAB, GB ~[]B, A, B any](a A) func(GFAB) GB {
|
||||
return FC.Flap(Map[GFAB, GB], a)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func Prepend[ENDO ~func(AS) AS, AS []A, A any](head A) ENDO {
|
||||
return array.Prepend[ENDO](head)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func Reverse[GT ~[]T, T any](as GT) GT {
|
||||
return array.Reverse(as)
|
||||
}
|
||||
|
||||
@@ -18,14 +18,11 @@ package nonempty
|
||||
import (
|
||||
G "github.com/IBM/fp-go/v2/array/generic"
|
||||
EM "github.com/IBM/fp-go/v2/endomorphism"
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/internal/array"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
S "github.com/IBM/fp-go/v2/semigroup"
|
||||
)
|
||||
|
||||
// NonEmptyArray represents an array with at least one element
|
||||
type NonEmptyArray[A any] []A
|
||||
|
||||
// Of constructs a single element array
|
||||
func Of[A any](first A) NonEmptyArray[A] {
|
||||
return G.Of[NonEmptyArray[A]](first)
|
||||
@@ -44,20 +41,24 @@ func From[A any](first A, data ...A) NonEmptyArray[A] {
|
||||
return buffer
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func IsEmpty[A any](_ NonEmptyArray[A]) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func IsNonEmpty[A any](_ NonEmptyArray[A]) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func MonadMap[A, B any](as NonEmptyArray[A], f func(a A) B) NonEmptyArray[B] {
|
||||
return G.MonadMap[NonEmptyArray[A], NonEmptyArray[B]](as, f)
|
||||
}
|
||||
|
||||
func Map[A, B any](f func(a A) B) func(NonEmptyArray[A]) NonEmptyArray[B] {
|
||||
return F.Bind2nd(MonadMap[A, B], f)
|
||||
//go:inline
|
||||
func Map[A, B any](f func(a A) B) Operator[A, B] {
|
||||
return G.Map[NonEmptyArray[A], NonEmptyArray[B]](f)
|
||||
}
|
||||
|
||||
func Reduce[A, B any](f func(B, A) B, initial B) func(NonEmptyArray[A]) B {
|
||||
@@ -72,22 +73,27 @@ func ReduceRight[A, B any](f func(A, B) B, initial B) func(NonEmptyArray[A]) B {
|
||||
}
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func Tail[A any](as NonEmptyArray[A]) []A {
|
||||
return as[1:]
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func Head[A any](as NonEmptyArray[A]) A {
|
||||
return as[0]
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func First[A any](as NonEmptyArray[A]) A {
|
||||
return as[0]
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func Last[A any](as NonEmptyArray[A]) A {
|
||||
return as[len(as)-1]
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func Size[A any](as NonEmptyArray[A]) int {
|
||||
return G.Size(as)
|
||||
}
|
||||
@@ -96,11 +102,11 @@ func Flatten[A any](mma NonEmptyArray[NonEmptyArray[A]]) NonEmptyArray[A] {
|
||||
return G.Flatten(mma)
|
||||
}
|
||||
|
||||
func MonadChain[A, B any](fa NonEmptyArray[A], f func(a A) NonEmptyArray[B]) NonEmptyArray[B] {
|
||||
func MonadChain[A, B any](fa NonEmptyArray[A], f Kleisli[A, B]) NonEmptyArray[B] {
|
||||
return G.MonadChain(fa, f)
|
||||
}
|
||||
|
||||
func Chain[A, B any](f func(A) NonEmptyArray[B]) func(NonEmptyArray[A]) NonEmptyArray[B] {
|
||||
func Chain[A, B any](f func(A) NonEmptyArray[B]) Operator[A, B] {
|
||||
return G.Chain[NonEmptyArray[A]](f)
|
||||
}
|
||||
|
||||
@@ -134,3 +140,89 @@ func Fold[A any](s S.Semigroup[A]) func(NonEmptyArray[A]) A {
|
||||
func Prepend[A any](head A) EM.Endomorphism[NonEmptyArray[A]] {
|
||||
return array.Prepend[EM.Endomorphism[NonEmptyArray[A]]](head)
|
||||
}
|
||||
|
||||
// ToNonEmptyArray attempts to convert a regular slice into a NonEmptyArray.
|
||||
// This function provides a safe way to create a NonEmptyArray from a slice that might be empty,
|
||||
// returning an Option type to handle the case where the input slice is empty.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The element type of the array
|
||||
//
|
||||
// Parameters:
|
||||
// - as: A regular slice that may or may not be empty
|
||||
//
|
||||
// Returns:
|
||||
// - Option[NonEmptyArray[A]]: Some(NonEmptyArray) if the input slice is non-empty, None if empty
|
||||
//
|
||||
// Behavior:
|
||||
// - If the input slice is empty, returns None
|
||||
// - If the input slice has at least one element, wraps it in Some and returns it as a NonEmptyArray
|
||||
// - The conversion is a type cast, so no data is copied
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Convert non-empty slice
|
||||
// numbers := []int{1, 2, 3}
|
||||
// result := ToNonEmptyArray(numbers) // Some(NonEmptyArray[1, 2, 3])
|
||||
//
|
||||
// // Convert empty slice
|
||||
// empty := []int{}
|
||||
// result := ToNonEmptyArray(empty) // None
|
||||
//
|
||||
// // Use with Option methods
|
||||
// numbers := []int{1, 2, 3}
|
||||
// result := ToNonEmptyArray(numbers)
|
||||
// if O.IsSome(result) {
|
||||
// nea := O.GetOrElse(F.Constant(From(0)))(result)
|
||||
// head := Head(nea) // 1
|
||||
// }
|
||||
//
|
||||
// Use cases:
|
||||
// - Safely converting user input or external data to NonEmptyArray
|
||||
// - Validating that a collection has at least one element before processing
|
||||
// - Converting results from functions that return regular slices
|
||||
// - Ensuring type safety when working with collections that must not be empty
|
||||
//
|
||||
// Example with validation:
|
||||
//
|
||||
// func processItems(items []string) Option[string] {
|
||||
// return F.Pipe2(
|
||||
// items,
|
||||
// ToNonEmptyArray[string],
|
||||
// O.Map(func(nea NonEmptyArray[string]) string {
|
||||
// return Head(nea) // Safe to get head since we know it's non-empty
|
||||
// }),
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// Example with error handling:
|
||||
//
|
||||
// items := []int{1, 2, 3}
|
||||
// result := ToNonEmptyArray(items)
|
||||
// switch {
|
||||
// case O.IsSome(result):
|
||||
// nea := O.GetOrElse(F.Constant(From(0)))(result)
|
||||
// fmt.Println("First item:", Head(nea))
|
||||
// case O.IsNone(result):
|
||||
// fmt.Println("Array is empty")
|
||||
// }
|
||||
//
|
||||
// Example with chaining:
|
||||
//
|
||||
// // Process only if non-empty
|
||||
// result := F.Pipe3(
|
||||
// []int{1, 2, 3},
|
||||
// ToNonEmptyArray[int],
|
||||
// O.Map(Map(func(x int) int { return x * 2 })),
|
||||
// O.Map(Head[int]),
|
||||
// ) // Some(2)
|
||||
//
|
||||
// Note: This function is particularly useful when working with APIs or functions
|
||||
// that return regular slices but you need the type-level guarantee that the
|
||||
// collection is non-empty for subsequent operations.
|
||||
func ToNonEmptyArray[A any](as []A) Option[NonEmptyArray[A]] {
|
||||
if G.IsEmpty(as) {
|
||||
return option.None[NonEmptyArray[A]]()
|
||||
}
|
||||
return option.Some(NonEmptyArray[A](as))
|
||||
}
|
||||
|
||||
370
v2/array/nonempty/array_test.go
Normal file
370
v2/array/nonempty/array_test.go
Normal file
@@ -0,0 +1,370 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package nonempty
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
O "github.com/IBM/fp-go/v2/option"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestToNonEmptyArray tests the ToNonEmptyArray function
|
||||
func TestToNonEmptyArray(t *testing.T) {
|
||||
t.Run("Convert non-empty slice of integers", func(t *testing.T) {
|
||||
input := []int{1, 2, 3}
|
||||
result := ToNonEmptyArray(input)
|
||||
|
||||
assert.True(t, O.IsSome(result))
|
||||
nea := O.GetOrElse(F.Constant(From(0)))(result)
|
||||
assert.Equal(t, 3, Size(nea))
|
||||
assert.Equal(t, 1, Head(nea))
|
||||
assert.Equal(t, 3, Last(nea))
|
||||
})
|
||||
|
||||
t.Run("Convert empty slice returns None", func(t *testing.T) {
|
||||
input := []int{}
|
||||
result := ToNonEmptyArray(input)
|
||||
|
||||
assert.True(t, O.IsNone(result))
|
||||
})
|
||||
|
||||
t.Run("Convert single element slice", func(t *testing.T) {
|
||||
input := []string{"hello"}
|
||||
result := ToNonEmptyArray(input)
|
||||
|
||||
assert.True(t, O.IsSome(result))
|
||||
nea := O.GetOrElse(F.Constant(From("")))(result)
|
||||
assert.Equal(t, 1, Size(nea))
|
||||
assert.Equal(t, "hello", Head(nea))
|
||||
})
|
||||
|
||||
t.Run("Convert non-empty slice of strings", func(t *testing.T) {
|
||||
input := []string{"a", "b", "c", "d"}
|
||||
result := ToNonEmptyArray(input)
|
||||
|
||||
assert.True(t, O.IsSome(result))
|
||||
nea := O.GetOrElse(F.Constant(From("")))(result)
|
||||
assert.Equal(t, 4, Size(nea))
|
||||
assert.Equal(t, "a", Head(nea))
|
||||
assert.Equal(t, "d", Last(nea))
|
||||
})
|
||||
|
||||
t.Run("Convert nil slice returns None", func(t *testing.T) {
|
||||
var input []int
|
||||
result := ToNonEmptyArray(input)
|
||||
|
||||
assert.True(t, O.IsNone(result))
|
||||
})
|
||||
|
||||
t.Run("Convert slice with struct elements", func(t *testing.T) {
|
||||
type Person struct {
|
||||
Name string
|
||||
Age int
|
||||
}
|
||||
input := []Person{
|
||||
{Name: "Alice", Age: 30},
|
||||
{Name: "Bob", Age: 25},
|
||||
}
|
||||
result := ToNonEmptyArray(input)
|
||||
|
||||
assert.True(t, O.IsSome(result))
|
||||
nea := O.GetOrElse(F.Constant(From(Person{})))(result)
|
||||
assert.Equal(t, 2, Size(nea))
|
||||
assert.Equal(t, "Alice", Head(nea).Name)
|
||||
})
|
||||
|
||||
t.Run("Convert slice with pointer elements", func(t *testing.T) {
|
||||
val1, val2 := 10, 20
|
||||
input := []*int{&val1, &val2}
|
||||
result := ToNonEmptyArray(input)
|
||||
|
||||
assert.True(t, O.IsSome(result))
|
||||
nea := O.GetOrElse(F.Constant(From[*int](nil)))(result)
|
||||
assert.Equal(t, 2, Size(nea))
|
||||
assert.Equal(t, 10, *Head(nea))
|
||||
})
|
||||
|
||||
t.Run("Convert large slice", func(t *testing.T) {
|
||||
input := make([]int, 1000)
|
||||
for i := range input {
|
||||
input[i] = i
|
||||
}
|
||||
result := ToNonEmptyArray(input)
|
||||
|
||||
assert.True(t, O.IsSome(result))
|
||||
nea := O.GetOrElse(F.Constant(From(0)))(result)
|
||||
assert.Equal(t, 1000, Size(nea))
|
||||
assert.Equal(t, 0, Head(nea))
|
||||
assert.Equal(t, 999, Last(nea))
|
||||
})
|
||||
|
||||
t.Run("Convert slice with float64 elements", func(t *testing.T) {
|
||||
input := []float64{1.5, 2.5, 3.5}
|
||||
result := ToNonEmptyArray(input)
|
||||
|
||||
assert.True(t, O.IsSome(result))
|
||||
nea := O.GetOrElse(F.Constant(From(0.0)))(result)
|
||||
assert.Equal(t, 3, Size(nea))
|
||||
assert.Equal(t, 1.5, Head(nea))
|
||||
})
|
||||
|
||||
t.Run("Convert slice with boolean elements", func(t *testing.T) {
|
||||
input := []bool{true, false, true}
|
||||
result := ToNonEmptyArray(input)
|
||||
|
||||
assert.True(t, O.IsSome(result))
|
||||
nea := O.GetOrElse(F.Constant(From(false)))(result)
|
||||
assert.Equal(t, 3, Size(nea))
|
||||
assert.True(t, Head(nea))
|
||||
})
|
||||
}
|
||||
|
||||
// TestToNonEmptyArrayWithOption tests ToNonEmptyArray with Option operations
|
||||
func TestToNonEmptyArrayWithOption(t *testing.T) {
|
||||
t.Run("Chain with Map to process elements", func(t *testing.T) {
|
||||
input := []int{1, 2, 3}
|
||||
result := F.Pipe2(
|
||||
input,
|
||||
ToNonEmptyArray[int],
|
||||
O.Map(Map(func(x int) int { return x * 2 })),
|
||||
)
|
||||
|
||||
assert.True(t, O.IsSome(result))
|
||||
nea := O.GetOrElse(F.Constant(From(0)))(result)
|
||||
assert.Equal(t, 2, Head(nea))
|
||||
assert.Equal(t, 6, Last(nea))
|
||||
})
|
||||
|
||||
t.Run("Chain with Map to get head", func(t *testing.T) {
|
||||
input := []string{"first", "second", "third"}
|
||||
result := F.Pipe2(
|
||||
input,
|
||||
ToNonEmptyArray[string],
|
||||
O.Map(Head[string]),
|
||||
)
|
||||
|
||||
assert.True(t, O.IsSome(result))
|
||||
value := O.GetOrElse(F.Constant(""))(result)
|
||||
assert.Equal(t, "first", value)
|
||||
})
|
||||
|
||||
t.Run("GetOrElse with default value for empty slice", func(t *testing.T) {
|
||||
input := []int{}
|
||||
defaultValue := From(42)
|
||||
result := F.Pipe2(
|
||||
input,
|
||||
ToNonEmptyArray[int],
|
||||
O.GetOrElse(F.Constant(defaultValue)),
|
||||
)
|
||||
|
||||
assert.Equal(t, 1, Size(result))
|
||||
assert.Equal(t, 42, Head(result))
|
||||
})
|
||||
|
||||
t.Run("GetOrElse with default value for non-empty slice", func(t *testing.T) {
|
||||
input := []int{1, 2, 3}
|
||||
defaultValue := From(42)
|
||||
result := F.Pipe2(
|
||||
input,
|
||||
ToNonEmptyArray[int],
|
||||
O.GetOrElse(F.Constant(defaultValue)),
|
||||
)
|
||||
|
||||
assert.Equal(t, 3, Size(result))
|
||||
assert.Equal(t, 1, Head(result))
|
||||
})
|
||||
|
||||
t.Run("Fold with Some case", func(t *testing.T) {
|
||||
input := []int{1, 2, 3}
|
||||
result := F.Pipe2(
|
||||
input,
|
||||
ToNonEmptyArray[int],
|
||||
O.Fold(
|
||||
F.Constant(0),
|
||||
func(nea NonEmptyArray[int]) int { return Head(nea) },
|
||||
),
|
||||
)
|
||||
|
||||
assert.Equal(t, 1, result)
|
||||
})
|
||||
|
||||
t.Run("Fold with None case", func(t *testing.T) {
|
||||
input := []int{}
|
||||
result := F.Pipe2(
|
||||
input,
|
||||
ToNonEmptyArray[int],
|
||||
O.Fold(
|
||||
F.Constant(-1),
|
||||
func(nea NonEmptyArray[int]) int { return Head(nea) },
|
||||
),
|
||||
)
|
||||
|
||||
assert.Equal(t, -1, result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestToNonEmptyArrayComposition tests composing ToNonEmptyArray with other operations
|
||||
func TestToNonEmptyArrayComposition(t *testing.T) {
|
||||
t.Run("Compose with filter-like operation", func(t *testing.T) {
|
||||
input := []int{1, 2, 3, 4, 5}
|
||||
// Filter even numbers then convert
|
||||
filtered := []int{}
|
||||
for _, x := range input {
|
||||
if x%2 == 0 {
|
||||
filtered = append(filtered, x)
|
||||
}
|
||||
}
|
||||
result := ToNonEmptyArray(filtered)
|
||||
|
||||
assert.True(t, O.IsSome(result))
|
||||
nea := O.GetOrElse(F.Constant(From(0)))(result)
|
||||
assert.Equal(t, 2, Size(nea))
|
||||
assert.Equal(t, 2, Head(nea))
|
||||
})
|
||||
|
||||
t.Run("Compose with map operation before conversion", func(t *testing.T) {
|
||||
input := []int{1, 2, 3}
|
||||
// Map then convert
|
||||
mapped := make([]int, len(input))
|
||||
for i, x := range input {
|
||||
mapped[i] = x * 10
|
||||
}
|
||||
result := ToNonEmptyArray(mapped)
|
||||
|
||||
assert.True(t, O.IsSome(result))
|
||||
nea := O.GetOrElse(F.Constant(From(0)))(result)
|
||||
assert.Equal(t, 10, Head(nea))
|
||||
assert.Equal(t, 30, Last(nea))
|
||||
})
|
||||
|
||||
t.Run("Chain multiple Option operations", func(t *testing.T) {
|
||||
input := []int{5, 10, 15}
|
||||
result := F.Pipe3(
|
||||
input,
|
||||
ToNonEmptyArray[int],
|
||||
O.Map(Map(func(x int) int { return x / 5 })),
|
||||
O.Map(func(nea NonEmptyArray[int]) int {
|
||||
return Head(nea) + Last(nea)
|
||||
}),
|
||||
)
|
||||
|
||||
assert.True(t, O.IsSome(result))
|
||||
value := O.GetOrElse(F.Constant(0))(result)
|
||||
assert.Equal(t, 4, value) // 1 + 3
|
||||
})
|
||||
}
|
||||
|
||||
// TestToNonEmptyArrayUseCases demonstrates practical use cases
|
||||
func TestToNonEmptyArrayUseCases(t *testing.T) {
|
||||
t.Run("Validate user input has at least one item", func(t *testing.T) {
|
||||
// Simulate user input
|
||||
userInput := []string{"item1", "item2"}
|
||||
|
||||
result := ToNonEmptyArray(userInput)
|
||||
if O.IsSome(result) {
|
||||
nea := O.GetOrElse(F.Constant(From("")))(result)
|
||||
firstItem := Head(nea)
|
||||
assert.Equal(t, "item1", firstItem)
|
||||
} else {
|
||||
t.Fatal("Expected Some but got None")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Process only non-empty collections", func(t *testing.T) {
|
||||
processItems := func(items []int) Option[int] {
|
||||
return F.Pipe2(
|
||||
items,
|
||||
ToNonEmptyArray[int],
|
||||
O.Map(func(nea NonEmptyArray[int]) int {
|
||||
// Safe to use Head since we know it's non-empty
|
||||
return Head(nea) * 2
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
result1 := processItems([]int{5, 10, 15})
|
||||
assert.True(t, O.IsSome(result1))
|
||||
assert.Equal(t, 10, O.GetOrElse(F.Constant(0))(result1))
|
||||
|
||||
result2 := processItems([]int{})
|
||||
assert.True(t, O.IsNone(result2))
|
||||
})
|
||||
|
||||
t.Run("Convert API response to NonEmptyArray", func(t *testing.T) {
|
||||
// Simulate API response
|
||||
type APIResponse struct {
|
||||
Items []string
|
||||
}
|
||||
|
||||
response := APIResponse{Items: []string{"data1", "data2", "data3"}}
|
||||
|
||||
result := F.Pipe2(
|
||||
response.Items,
|
||||
ToNonEmptyArray[string],
|
||||
O.Map(func(nea NonEmptyArray[string]) string {
|
||||
return "First item: " + Head(nea)
|
||||
}),
|
||||
)
|
||||
|
||||
assert.True(t, O.IsSome(result))
|
||||
message := O.GetOrElse(F.Constant("No items"))(result)
|
||||
assert.Equal(t, "First item: data1", message)
|
||||
})
|
||||
|
||||
t.Run("Ensure collection is non-empty before processing", func(t *testing.T) {
|
||||
calculateAverage := func(numbers []float64) Option[float64] {
|
||||
return F.Pipe2(
|
||||
numbers,
|
||||
ToNonEmptyArray[float64],
|
||||
O.Map(func(nea NonEmptyArray[float64]) float64 {
|
||||
sum := 0.0
|
||||
for _, n := range nea {
|
||||
sum += n
|
||||
}
|
||||
return sum / float64(Size(nea))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
result1 := calculateAverage([]float64{10.0, 20.0, 30.0})
|
||||
assert.True(t, O.IsSome(result1))
|
||||
assert.Equal(t, 20.0, O.GetOrElse(F.Constant(0.0))(result1))
|
||||
|
||||
result2 := calculateAverage([]float64{})
|
||||
assert.True(t, O.IsNone(result2))
|
||||
})
|
||||
|
||||
t.Run("Safe head extraction with type guarantee", func(t *testing.T) {
|
||||
getFirstOrDefault := func(items []string, defaultValue string) string {
|
||||
return F.Pipe2(
|
||||
items,
|
||||
ToNonEmptyArray[string],
|
||||
O.Fold(
|
||||
F.Constant(defaultValue),
|
||||
Head[string],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
result1 := getFirstOrDefault([]string{"a", "b", "c"}, "default")
|
||||
assert.Equal(t, "a", result1)
|
||||
|
||||
result2 := getFirstOrDefault([]string{}, "default")
|
||||
assert.Equal(t, "default", result2)
|
||||
})
|
||||
}
|
||||
20
v2/array/nonempty/types.go
Normal file
20
v2/array/nonempty/types.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package nonempty
|
||||
|
||||
import "github.com/IBM/fp-go/v2/option"
|
||||
|
||||
type (
|
||||
// NonEmptyArray represents an array that is guaranteed to have at least one element.
|
||||
// This provides compile-time safety for operations that require non-empty collections.
|
||||
NonEmptyArray[A any] []A
|
||||
|
||||
// Kleisli represents a Kleisli arrow for the NonEmptyArray monad.
|
||||
// It's a function from A to NonEmptyArray[B], used for composing operations that produce non-empty arrays.
|
||||
Kleisli[A, B any] = func(A) NonEmptyArray[B]
|
||||
|
||||
// Operator represents a function that transforms one NonEmptyArray into another.
|
||||
// It takes a NonEmptyArray[A] and produces a NonEmptyArray[B].
|
||||
Operator[A, B any] = Kleisli[NonEmptyArray[A], B]
|
||||
|
||||
// Option represents an optional value that may or may not be present.
|
||||
Option[A any] = option.Option[A]
|
||||
)
|
||||
@@ -3,7 +3,14 @@ package array
|
||||
import "github.com/IBM/fp-go/v2/option"
|
||||
|
||||
type (
|
||||
Kleisli[A, B any] = func(A) []B
|
||||
// Kleisli represents a Kleisli arrow for arrays.
|
||||
// It's a function from A to []B, used for composing operations that produce arrays.
|
||||
Kleisli[A, B any] = func(A) []B
|
||||
|
||||
// Operator represents a function that transforms one array into another.
|
||||
// It takes a []A and produces a []B.
|
||||
Operator[A, B any] = Kleisli[[]A, B]
|
||||
Option[A any] = option.Option[A]
|
||||
|
||||
// Option represents an optional value that may or may not be present.
|
||||
Option[A any] = option.Option[A]
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
// assert.ArrayNotEmpty(arr)(t)
|
||||
//
|
||||
// // Partial application - create reusable assertions
|
||||
// isPositive := assert.That(func(n int) bool { return n > 0 })
|
||||
// isPositive := assert.That(N.MoreThan(0))
|
||||
// // Later, apply to different values:
|
||||
// isPositive(42)(t) // Passes
|
||||
// isPositive(-5)(t) // Fails
|
||||
@@ -416,7 +416,7 @@ func NotContainsKey[T any, K comparable](expected K) Kleisli[map[K]T] {
|
||||
//
|
||||
// func TestThat(t *testing.T) {
|
||||
// // Test if a number is positive
|
||||
// isPositive := func(n int) bool { return n > 0 }
|
||||
// isPositive := N.MoreThan(0)
|
||||
// assert.That(isPositive)(42)(t) // Passes
|
||||
// assert.That(isPositive)(-5)(t) // Fails
|
||||
//
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/IBM/fp-go/v2/optics/prism"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
S "github.com/IBM/fp-go/v2/string"
|
||||
)
|
||||
|
||||
func TestEqual(t *testing.T) {
|
||||
@@ -334,7 +335,7 @@ func TestThat(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("should work with string predicates", func(t *testing.T) {
|
||||
startsWithH := func(s string) bool { return len(s) > 0 && s[0] == 'h' }
|
||||
startsWithH := func(s string) bool { return S.IsNonEmpty(s) && s[0] == 'h' }
|
||||
result := That(startsWithH)("hello")(t)
|
||||
if !result {
|
||||
t.Error("Expected That to pass for string predicate")
|
||||
@@ -484,7 +485,7 @@ func TestLocal(t *testing.T) {
|
||||
t.Run("should compose with other assertions", func(t *testing.T) {
|
||||
// Create multiple focused assertions
|
||||
nameNotEmpty := Local(func(u User) string { return u.Name })(
|
||||
That(func(name string) bool { return len(name) > 0 }),
|
||||
That(S.IsNonEmpty),
|
||||
)
|
||||
ageInRange := Local(func(u User) int { return u.Age })(
|
||||
That(func(age int) bool { return age >= 18 && age <= 100 }),
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/IBM/fp-go/v2/assert"
|
||||
N "github.com/IBM/fp-go/v2/number"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
)
|
||||
|
||||
@@ -98,7 +99,7 @@ func Example_resultAssertions() {
|
||||
var t *testing.T // placeholder for example
|
||||
|
||||
// Assert success
|
||||
successResult := result.Of[int](42)
|
||||
successResult := result.Of(42)
|
||||
assert.Success(successResult)(t)
|
||||
|
||||
// Assert failure
|
||||
@@ -111,7 +112,7 @@ func Example_predicateAssertions() {
|
||||
var t *testing.T // placeholder for example
|
||||
|
||||
// Test if a number is positive
|
||||
isPositive := func(n int) bool { return n > 0 }
|
||||
isPositive := N.MoreThan(0)
|
||||
assert.That(isPositive)(42)(t)
|
||||
|
||||
// Test if a string is uppercase
|
||||
|
||||
@@ -12,11 +12,24 @@ import (
|
||||
)
|
||||
|
||||
type (
|
||||
Result[T any] = result.Result[T]
|
||||
Reader = reader.Reader[*testing.T, bool]
|
||||
Kleisli[T any] = reader.Reader[T, Reader]
|
||||
Predicate[T any] = predicate.Predicate[T]
|
||||
Lens[S, T any] = lens.Lens[S, T]
|
||||
// Result represents a computation that may fail with an error.
|
||||
Result[T any] = result.Result[T]
|
||||
|
||||
// Reader represents a test assertion that depends on a testing.T context and returns a boolean.
|
||||
Reader = reader.Reader[*testing.T, bool]
|
||||
|
||||
// Kleisli represents a function that produces a test assertion Reader from a value of type T.
|
||||
Kleisli[T any] = reader.Reader[T, Reader]
|
||||
|
||||
// Predicate represents a function that tests a value of type T and returns a boolean.
|
||||
Predicate[T any] = predicate.Predicate[T]
|
||||
|
||||
// Lens is a functional reference to a subpart of a data structure.
|
||||
Lens[S, T any] = lens.Lens[S, T]
|
||||
|
||||
// Optional is an optic that focuses on a value that may or may not be present.
|
||||
Optional[S, T any] = optional.Optional[S, T]
|
||||
Prism[S, T any] = prism.Prism[S, T]
|
||||
|
||||
// Prism is an optic that focuses on a case of a sum type.
|
||||
Prism[S, T any] = prism.Prism[S, T]
|
||||
)
|
||||
|
||||
@@ -18,5 +18,7 @@ package boolean
|
||||
import "github.com/IBM/fp-go/v2/monoid"
|
||||
|
||||
type (
|
||||
// Monoid represents a monoid structure for boolean values.
|
||||
// A monoid provides an associative binary operation and an identity element.
|
||||
Monoid = monoid.Monoid[bool]
|
||||
)
|
||||
|
||||
@@ -7,9 +7,14 @@ import (
|
||||
)
|
||||
|
||||
type (
|
||||
// Result represents a computation that may fail with an error.
|
||||
// It's an alias for Either[error, T].
|
||||
Result[T any] = result.Result[T]
|
||||
|
||||
// Prism is an optic that focuses on a case of a sum type.
|
||||
// It provides a way to extract and construct values of a specific variant.
|
||||
Prism[S, A any] = prism.Prism[S, A]
|
||||
|
||||
// Option represents an optional value that may or may not be present.
|
||||
Option[T any] = option.Option[T]
|
||||
)
|
||||
|
||||
623
v2/circuitbreaker/circuitbreaker.go
Normal file
623
v2/circuitbreaker/circuitbreaker.go
Normal file
@@ -0,0 +1,623 @@
|
||||
package circuitbreaker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/IBM/fp-go/v2/either"
|
||||
"github.com/IBM/fp-go/v2/function"
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/identity"
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
"github.com/IBM/fp-go/v2/ioref"
|
||||
"github.com/IBM/fp-go/v2/lazy"
|
||||
"github.com/IBM/fp-go/v2/optics/lens"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
"github.com/IBM/fp-go/v2/retry"
|
||||
)
|
||||
|
||||
var (
|
||||
canaryRequestLens = lens.MakeLensWithName(
|
||||
func(os openState) bool { return os.canaryRequest },
|
||||
func(os openState, flag bool) openState {
|
||||
os.canaryRequest = flag
|
||||
return os
|
||||
},
|
||||
"openState.CanaryRequest",
|
||||
)
|
||||
|
||||
retryStatusLens = lens.MakeLensWithName(
|
||||
func(os openState) retry.RetryStatus { return os.retryStatus },
|
||||
func(os openState, status retry.RetryStatus) openState {
|
||||
os.retryStatus = status
|
||||
return os
|
||||
},
|
||||
"openState.RetryStatus",
|
||||
)
|
||||
|
||||
resetAtLens = lens.MakeLensWithName(
|
||||
func(os openState) time.Time { return os.resetAt },
|
||||
func(os openState, tm time.Time) openState {
|
||||
os.resetAt = tm
|
||||
return os
|
||||
},
|
||||
"openState.ResetAt",
|
||||
)
|
||||
|
||||
openedAtLens = lens.MakeLensWithName(
|
||||
func(os openState) time.Time { return os.openedAt },
|
||||
func(os openState, tm time.Time) openState {
|
||||
os.openedAt = tm
|
||||
return os
|
||||
},
|
||||
"openState.OpenedAt",
|
||||
)
|
||||
|
||||
createClosedCircuit = either.Right[openState, ClosedState]
|
||||
createOpenCircuit = either.Left[ClosedState, openState]
|
||||
|
||||
// MakeClosedIORef creates an IORef containing a closed circuit breaker state.
|
||||
// It wraps the provided ClosedState in a Right (closed) BreakerState and creates
|
||||
// a mutable reference to it.
|
||||
//
|
||||
// Parameters:
|
||||
// - closedState: The initial closed state configuration
|
||||
//
|
||||
// Returns:
|
||||
// - An IO operation that creates an IORef[BreakerState] initialized to closed state
|
||||
//
|
||||
// Thread Safety: The returned IORef[BreakerState] is thread-safe. It uses atomic
|
||||
// operations for all read/write/modify operations. The BreakerState itself is immutable.
|
||||
MakeClosedIORef = F.Flow2(
|
||||
createClosedCircuit,
|
||||
ioref.MakeIORef,
|
||||
)
|
||||
|
||||
// IsOpen checks if a BreakerState is in the open state.
|
||||
// Returns true if the circuit breaker is open (blocking requests), false otherwise.
|
||||
IsOpen = either.IsLeft[openState, ClosedState]
|
||||
|
||||
// IsClosed checks if a BreakerState is in the closed state.
|
||||
// Returns true if the circuit breaker is closed (allowing requests), false otherwise.
|
||||
IsClosed = either.IsRight[openState, ClosedState]
|
||||
|
||||
// modifyV creates a Reader that sequences an IORef modification operation.
|
||||
// It takes an IORef[BreakerState] and returns a Reader that, when given an endomorphism
|
||||
// (a function from BreakerState to BreakerState), produces an IO operation that modifies
|
||||
// the IORef and returns the new state.
|
||||
//
|
||||
// This is used internally to create state modification operations that can be composed
|
||||
// with other Reader-based operations in the circuit breaker logic.
|
||||
//
|
||||
// Thread Safety: The IORef modification is atomic. Multiple concurrent calls will be
|
||||
// serialized by the IORef's atomic operations.
|
||||
//
|
||||
// Type signature: Reader[IORef[BreakerState], IO[Endomorphism[BreakerState]]]
|
||||
modifyV = reader.Sequence(ioref.Modify[BreakerState])
|
||||
|
||||
initialRetry = retry.DefaultRetryStatus
|
||||
|
||||
// testCircuit sets the canaryRequest flag to true in an openState.
|
||||
// This is used to mark that the circuit breaker is in half-open state,
|
||||
// allowing a single test request (canary) to check if the service has recovered.
|
||||
//
|
||||
// When canaryRequest is true:
|
||||
// - One request is allowed through to test the service
|
||||
// - If the canary succeeds, the circuit closes
|
||||
// - If the canary fails, the circuit remains open with an extended reset time
|
||||
//
|
||||
// Thread Safety: This is a pure function that returns a new openState; it does not
|
||||
// modify its input. Safe for concurrent use.
|
||||
//
|
||||
// Type signature: Endomorphism[openState]
|
||||
testCircuit = canaryRequestLens.Set(true)
|
||||
)
|
||||
|
||||
// makeOpenCircuitFromPolicy creates a function that constructs an openState from a retry policy.
|
||||
// This is a curried function that takes a retry policy and returns a function that takes a retry status
|
||||
// and current time to produce an openState with calculated reset time.
|
||||
//
|
||||
// The function applies the retry policy to determine the next retry delay and calculates
|
||||
// the resetAt time by adding the delay to the current time. If no previous delay exists
|
||||
// (first failure), the resetAt is set to the current time.
|
||||
//
|
||||
// Parameters:
|
||||
// - policy: The retry policy that determines backoff strategy (e.g., exponential backoff)
|
||||
//
|
||||
// Returns:
|
||||
// - A curried function that takes:
|
||||
// 1. rs (retry.RetryStatus): The current retry status containing retry count and previous delay
|
||||
// 2. ct (time.Time): The current time when the circuit is opening
|
||||
// And returns an openState with:
|
||||
// - openedAt: Set to the current time (ct)
|
||||
// - resetAt: Current time plus the delay from the retry policy
|
||||
// - retryStatus: The updated retry status from applying the policy
|
||||
// - canaryRequest: false (will be set to true when reset time is reached)
|
||||
//
|
||||
// Thread Safety: This is a pure function that creates new openState instances.
|
||||
// Safe for concurrent use.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// policy := retry.ExponentialBackoff(1*time.Second, 2.0, 10)
|
||||
// makeOpen := makeOpenCircuitFromPolicy(policy)
|
||||
// openState := makeOpen(retry.DefaultRetryStatus)(time.Now())
|
||||
// // openState.resetAt will be approximately 1 second from now
|
||||
func makeOpenCircuitFromPolicy(policy retry.RetryPolicy) func(rs retry.RetryStatus) func(ct time.Time) openState {
|
||||
|
||||
return func(rs retry.RetryStatus) func(ct time.Time) openState {
|
||||
|
||||
retryStatus := retry.ApplyPolicy(policy, rs)
|
||||
|
||||
return func(ct time.Time) openState {
|
||||
|
||||
resetTime := F.Pipe2(
|
||||
retryStatus,
|
||||
retry.PreviousDelayLens.Get,
|
||||
option.Fold(
|
||||
F.Pipe1(
|
||||
ct,
|
||||
lazy.Of,
|
||||
),
|
||||
ct.Add,
|
||||
),
|
||||
)
|
||||
|
||||
return openState{openedAt: ct, resetAt: resetTime, retryStatus: retryStatus}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extendOpenCircuitFromMakeCircuit creates a function that extends the open state of a circuit breaker
|
||||
// when a canary request fails. It takes a circuit maker function and returns a function that,
|
||||
// given the current time, produces an endomorphism that updates an openState.
|
||||
//
|
||||
// This function is used when a canary request (test request in half-open state) fails.
|
||||
// It extends the circuit breaker's open period by:
|
||||
// 1. Extracting the current retry status from the open state
|
||||
// 2. Using the makeCircuit function to calculate a new open state with updated retry status
|
||||
// 3. Applying the current time to get the new state
|
||||
// 4. Setting the canaryRequest flag to true to allow another test request later
|
||||
//
|
||||
// Parameters:
|
||||
// - makeCircuit: A function that creates an openState from a retry status and current time.
|
||||
// This is typically created by makeOpenCircuitFromPolicy.
|
||||
//
|
||||
// Returns:
|
||||
// - A curried function that takes:
|
||||
// 1. ct (time.Time): The current time when extending the circuit
|
||||
// And returns an Endomorphism[openState] that:
|
||||
// - Increments the retry count
|
||||
// - Calculates a new resetAt time based on the retry policy (typically with exponential backoff)
|
||||
// - Sets canaryRequest to true for the next test attempt
|
||||
//
|
||||
// Thread Safety: This is a pure function that returns new openState instances.
|
||||
// Safe for concurrent use.
|
||||
//
|
||||
// Usage Context:
|
||||
// - Called when a canary request fails in the half-open state
|
||||
// - Extends the open period with increased backoff delay
|
||||
// - Prepares the circuit for another canary attempt at the new resetAt time
|
||||
func extendOpenCircuitFromMakeCircuit(
|
||||
makeCircuit func(rs retry.RetryStatus) func(ct time.Time) openState,
|
||||
) func(time.Time) Endomorphism[openState] {
|
||||
return func(ct time.Time) Endomorphism[openState] {
|
||||
return F.Flow4(
|
||||
retryStatusLens.Get,
|
||||
makeCircuit,
|
||||
identity.Flap[openState](ct),
|
||||
testCircuit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// isResetTimeExceeded checks if the reset time for an open circuit has been exceeded.
|
||||
// This is used to determine if the circuit breaker should transition from open to half-open state
|
||||
// by allowing a canary request.
|
||||
//
|
||||
// The function returns an option.Kleisli that succeeds (returns Some) only when:
|
||||
// 1. The circuit is not already in canary mode (canaryRequest is false)
|
||||
// 2. The current time is after the resetAt time
|
||||
//
|
||||
// Parameters:
|
||||
// - ct: The current time to compare against the reset time
|
||||
//
|
||||
// Returns:
|
||||
// - An option.Kleisli[openState, openState] that:
|
||||
// - Returns Some(openState) if the reset time has been exceeded and no canary is active
|
||||
// - Returns None if the reset time has not been exceeded or a canary request is already active
|
||||
//
|
||||
// Thread Safety: This is a pure function that does not modify its input.
|
||||
// Safe for concurrent use.
|
||||
//
|
||||
// Usage Context:
|
||||
// - Called when the circuit is open to check if it's time to attempt a canary request
|
||||
// - If this returns Some, the circuit transitions to half-open state (canary mode)
|
||||
// - If this returns None, the circuit remains fully open and requests are blocked
|
||||
func isResetTimeExceeded(ct time.Time) option.Kleisli[openState, openState] {
|
||||
return option.FromPredicate(func(open openState) bool {
|
||||
return !open.canaryRequest && ct.After(resetAtLens.Get(open))
|
||||
})
|
||||
}
|
||||
|
||||
// handleSuccessOnClosed handles a successful request when the circuit breaker is in closed state.
|
||||
// It updates the closed state by recording the success and returns an IO operation that
|
||||
// modifies the breaker state.
|
||||
//
|
||||
// This function is part of the circuit breaker's state management for the closed state.
|
||||
// When a request succeeds in closed state:
|
||||
// 1. The current time is obtained
|
||||
// 2. The addSuccess function is called with the current time to update the ClosedState
|
||||
// 3. The updated ClosedState is wrapped in a Right (closed) BreakerState
|
||||
// 4. The breaker state is modified with the new state
|
||||
//
|
||||
// Parameters:
|
||||
// - currentTime: An IO operation that provides the current time
|
||||
// - addSuccess: A Reader that takes a time and returns an endomorphism for ClosedState,
|
||||
// typically resetting failure counters or history
|
||||
//
|
||||
// Returns:
|
||||
// - An io.Kleisli that takes another io.Kleisli and chains them together.
|
||||
// The outer Kleisli takes an Endomorphism[BreakerState] and returns BreakerState.
|
||||
// This allows composing the success handling with other state modifications.
|
||||
//
|
||||
// Thread Safety: This function creates IO operations that will atomically modify the
|
||||
// IORef[BreakerState] when executed. The state modifications are thread-safe.
|
||||
//
|
||||
// Type signature:
|
||||
//
|
||||
// io.Kleisli[io.Kleisli[Endomorphism[BreakerState], BreakerState], BreakerState]
|
||||
//
|
||||
// Usage Context:
|
||||
// - Called when a request succeeds while the circuit is closed
|
||||
// - Resets failure tracking (counter or history) in the ClosedState
|
||||
// - Keeps the circuit in closed state
|
||||
func handleSuccessOnClosed(
|
||||
currentTime IO[time.Time],
|
||||
addSuccess Reader[time.Time, Endomorphism[ClosedState]],
|
||||
) io.Kleisli[io.Kleisli[Endomorphism[BreakerState], BreakerState], BreakerState] {
|
||||
return F.Flow2(
|
||||
io.Chain,
|
||||
identity.Flap[IO[BreakerState]](F.Pipe1(
|
||||
currentTime,
|
||||
io.Map(F.Flow2(
|
||||
addSuccess,
|
||||
either.Map[openState],
|
||||
)))),
|
||||
)
|
||||
}
|
||||
|
||||
// handleFailureOnClosed handles a failed request when the circuit breaker is in closed state.
|
||||
// It updates the closed state by recording the failure and checks if the circuit should open.
|
||||
//
|
||||
// This function is part of the circuit breaker's state management for the closed state.
|
||||
// When a request fails in closed state:
|
||||
// 1. The current time is obtained
|
||||
// 2. The addError function is called to record the failure in the ClosedState
|
||||
// 3. The checkClosedState function is called to determine if the failure threshold is exceeded
|
||||
// 4. If the threshold is exceeded (Check returns None):
|
||||
// - The circuit transitions to open state using openCircuit
|
||||
// - A new openState is created with resetAt time calculated from the retry policy
|
||||
// 5. If the threshold is not exceeded (Check returns Some):
|
||||
// - The circuit remains closed with the updated failure tracking
|
||||
//
|
||||
// Parameters:
|
||||
// - currentTime: An IO operation that provides the current time
|
||||
// - addError: A Reader that takes a time and returns an endomorphism for ClosedState,
|
||||
// recording a failure (incrementing counter or adding to history)
|
||||
// - checkClosedState: A Reader that takes a time and returns an option.Kleisli that checks
|
||||
// if the ClosedState should remain closed. Returns Some if circuit stays closed, None if it should open.
|
||||
// - openCircuit: A Reader that takes a time and returns an openState with calculated resetAt time
|
||||
//
|
||||
// Returns:
|
||||
// - An io.Kleisli that takes another io.Kleisli and chains them together.
|
||||
// The outer Kleisli takes an Endomorphism[BreakerState] and returns BreakerState.
|
||||
// This allows composing the failure handling with other state modifications.
|
||||
//
|
||||
// Thread Safety: This function creates IO operations that will atomically modify the
|
||||
// IORef[BreakerState] when executed. The state modifications are thread-safe.
|
||||
//
|
||||
// Type signature:
|
||||
//
|
||||
// io.Kleisli[io.Kleisli[Endomorphism[BreakerState], BreakerState], BreakerState]
|
||||
//
|
||||
// State Transitions:
|
||||
// - Closed -> Closed: When failure threshold is not exceeded (Some from checkClosedState)
|
||||
// - Closed -> Open: When failure threshold is exceeded (None from checkClosedState)
|
||||
//
|
||||
// Usage Context:
|
||||
// - Called when a request fails while the circuit is closed
|
||||
// - Records the failure in the ClosedState (counter or history)
|
||||
// - May trigger transition to open state if threshold is exceeded
|
||||
func handleFailureOnClosed(
|
||||
currentTime IO[time.Time],
|
||||
addError Reader[time.Time, Endomorphism[ClosedState]],
|
||||
checkClosedState Reader[time.Time, option.Kleisli[ClosedState, ClosedState]],
|
||||
openCircuit Reader[time.Time, openState],
|
||||
) io.Kleisli[io.Kleisli[Endomorphism[BreakerState], BreakerState], BreakerState] {
|
||||
|
||||
return F.Flow2(
|
||||
io.Chain,
|
||||
identity.Flap[IO[BreakerState]](F.Pipe1(
|
||||
currentTime,
|
||||
io.Map(func(ct time.Time) either.Operator[openState, ClosedState, ClosedState] {
|
||||
return either.Chain(F.Flow3(
|
||||
addError(ct),
|
||||
checkClosedState(ct),
|
||||
option.Fold(
|
||||
F.Pipe2(
|
||||
ct,
|
||||
lazy.Of,
|
||||
lazy.Map(F.Flow2(
|
||||
openCircuit,
|
||||
createOpenCircuit,
|
||||
)),
|
||||
),
|
||||
createClosedCircuit,
|
||||
),
|
||||
))
|
||||
}))),
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
// MakeCircuitBreaker creates a circuit breaker implementation for a higher-kinded type.
|
||||
//
|
||||
// This is a generic circuit breaker factory that works with any monad-like type (HKTT).
|
||||
// It implements the circuit breaker pattern by wrapping operations and managing state transitions
|
||||
// between closed, open, and half-open states based on failure rates and retry policies.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - E: The error type
|
||||
// - T: The success value type
|
||||
// - HKTT: The higher-kinded type representing the computation (e.g., IO[T], ReaderIO[R, T])
|
||||
// - HKTOP: The higher-kinded type for operators (e.g., IO[func(HKTT) HKTT])
|
||||
// - HKTHKTT: The nested higher-kinded type (e.g., IO[IO[T]])
|
||||
//
|
||||
// Parameters:
|
||||
// - left: Constructs an error result in HKTT from an error value
|
||||
// - chainFirstIOK: Chains an IO operation that runs after success, preserving the original value
|
||||
// - chainFirstLeftIOK: Chains an IO operation that runs after error, preserving the original error
|
||||
// - fromIO: Lifts an IO operation into HKTOP
|
||||
// - flap: Applies a value to a function wrapped in a higher-kinded type
|
||||
// - flatten: Flattens nested higher-kinded types (join operation)
|
||||
// - currentTime: IO operation that provides the current time
|
||||
// - closedState: The initial closed state configuration
|
||||
// - makeError: Creates an error from a reset time when the circuit is open
|
||||
// - checkError: Predicate to determine if an error should trigger circuit breaker logic
|
||||
// - policy: Retry policy for determining reset times when circuit opens
|
||||
// - logger: Logging function for circuit breaker events
|
||||
//
|
||||
// Thread Safety: The returned State monad creates operations that are thread-safe when
|
||||
// executed. The IORef[BreakerState] uses atomic operations for all state modifications.
|
||||
// Multiple concurrent requests will be properly serialized at the IORef level.
|
||||
//
|
||||
// Returns:
|
||||
// - A State monad that transforms a pair of (IORef[BreakerState], HKTT) into HKTT,
|
||||
// applying circuit breaker logic to the computation
|
||||
func MakeCircuitBreaker[E, T, HKTT, HKTOP, HKTHKTT any](
|
||||
|
||||
left func(E) HKTT,
|
||||
chainFirstIOK func(io.Kleisli[T, BreakerState]) func(HKTT) HKTT,
|
||||
chainFirstLeftIOK func(io.Kleisli[E, BreakerState]) func(HKTT) HKTT,
|
||||
|
||||
fromIO func(IO[func(HKTT) HKTT]) HKTOP,
|
||||
flap func(HKTT) func(HKTOP) HKTHKTT,
|
||||
flatten func(HKTHKTT) HKTT,
|
||||
|
||||
currentTime IO[time.Time],
|
||||
closedState ClosedState,
|
||||
makeError Reader[time.Time, E],
|
||||
checkError option.Kleisli[E, E],
|
||||
policy retry.RetryPolicy,
|
||||
metrics Metrics,
|
||||
) State[Pair[IORef[BreakerState], HKTT], HKTT] {
|
||||
|
||||
type Operator = func(HKTT) HKTT
|
||||
|
||||
addSuccess := reader.From1(ClosedState.AddSuccess)
|
||||
addError := reader.From1(ClosedState.AddError)
|
||||
checkClosedState := reader.From1(ClosedState.Check)
|
||||
|
||||
closedCircuit := createClosedCircuit(closedState.Empty())
|
||||
makeOpenCircuit := makeOpenCircuitFromPolicy(policy)
|
||||
|
||||
openCircuit := F.Pipe1(
|
||||
initialRetry,
|
||||
makeOpenCircuit,
|
||||
)
|
||||
|
||||
extendOpenCircuit := extendOpenCircuitFromMakeCircuit(makeOpenCircuit)
|
||||
|
||||
failWithError := F.Flow4(
|
||||
resetAtLens.Get,
|
||||
makeError,
|
||||
left,
|
||||
reader.Of[HKTT],
|
||||
)
|
||||
|
||||
handleSuccess := handleSuccessOnClosed(currentTime, addSuccess)
|
||||
handleFailure := handleFailureOnClosed(currentTime, addError, checkClosedState, openCircuit)
|
||||
|
||||
onClosed := func(modify io.Kleisli[Endomorphism[BreakerState], BreakerState]) Operator {
|
||||
|
||||
return F.Flow2(
|
||||
// error case
|
||||
chainFirstLeftIOK(F.Flow3(
|
||||
checkError,
|
||||
option.Fold(
|
||||
// the error is not applicable, handle as success
|
||||
F.Pipe2(
|
||||
modify,
|
||||
handleSuccess,
|
||||
lazy.Of,
|
||||
),
|
||||
// the error is relevant, record it
|
||||
F.Pipe2(
|
||||
modify,
|
||||
handleFailure,
|
||||
reader.Of[E],
|
||||
),
|
||||
),
|
||||
// metering
|
||||
io.ChainFirst(either.Fold(
|
||||
F.Flow2(
|
||||
openedAtLens.Get,
|
||||
metrics.Open,
|
||||
),
|
||||
func(c ClosedState) IO[Void] {
|
||||
return io.Of(function.VOID)
|
||||
},
|
||||
)),
|
||||
)),
|
||||
// good case
|
||||
chainFirstIOK(F.Pipe2(
|
||||
modify,
|
||||
handleSuccess,
|
||||
reader.Of[T],
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
onCanary := func(modify io.Kleisli[Endomorphism[BreakerState], BreakerState]) Operator {
|
||||
|
||||
handleSuccess := F.Pipe2(
|
||||
closedCircuit,
|
||||
reader.Of[BreakerState],
|
||||
modify,
|
||||
)
|
||||
|
||||
return F.Flow2(
|
||||
// the canary request fails
|
||||
chainFirstLeftIOK(F.Flow2(
|
||||
checkError,
|
||||
option.Fold(
|
||||
// the canary request succeeds, we close the circuit
|
||||
F.Pipe1(
|
||||
handleSuccess,
|
||||
lazy.Of,
|
||||
),
|
||||
// the canary request fails, we extend the circuit
|
||||
F.Pipe1(
|
||||
F.Pipe1(
|
||||
currentTime,
|
||||
io.Chain(func(ct time.Time) IO[BreakerState] {
|
||||
return F.Pipe1(
|
||||
F.Flow2(
|
||||
either.Fold(
|
||||
extendOpenCircuit(ct),
|
||||
F.Pipe1(
|
||||
openCircuit(ct),
|
||||
reader.Of[ClosedState],
|
||||
),
|
||||
),
|
||||
createOpenCircuit,
|
||||
),
|
||||
modify,
|
||||
)
|
||||
}),
|
||||
),
|
||||
reader.Of[E],
|
||||
),
|
||||
),
|
||||
)),
|
||||
// the canary request succeeds, we'll close the circuit
|
||||
chainFirstIOK(F.Pipe1(
|
||||
handleSuccess,
|
||||
reader.Of[T],
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
onOpen := func(ref IORef[BreakerState]) Operator {
|
||||
|
||||
modify := modifyV(ref)
|
||||
|
||||
return F.Pipe3(
|
||||
currentTime,
|
||||
io.Chain(func(ct time.Time) IO[Operator] {
|
||||
return F.Pipe1(
|
||||
ref,
|
||||
ioref.ModifyWithResult(either.Fold(
|
||||
func(open openState) Pair[BreakerState, Operator] {
|
||||
return option.Fold(
|
||||
func() Pair[BreakerState, Operator] {
|
||||
return pair.MakePair(createOpenCircuit(open), failWithError(open))
|
||||
},
|
||||
func(open openState) Pair[BreakerState, Operator] {
|
||||
return pair.MakePair(createOpenCircuit(testCircuit(open)), onCanary(modify))
|
||||
},
|
||||
)(isResetTimeExceeded(ct)(open))
|
||||
},
|
||||
func(closed ClosedState) Pair[BreakerState, Operator] {
|
||||
return pair.MakePair(createClosedCircuit(closed), onClosed(modify))
|
||||
},
|
||||
)),
|
||||
)
|
||||
}),
|
||||
fromIO,
|
||||
func(src HKTOP) Operator {
|
||||
return func(rdr HKTT) HKTT {
|
||||
return F.Pipe2(
|
||||
src,
|
||||
flap(rdr),
|
||||
flatten,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return func(e Pair[IORef[BreakerState], HKTT]) Pair[Pair[IORef[BreakerState], HKTT], HKTT] {
|
||||
return pair.MakePair(e, onOpen(pair.Head(e))(pair.Tail(e)))
|
||||
}
|
||||
}
|
||||
|
||||
// MakeSingletonBreaker creates a singleton circuit breaker operator for a higher-kinded type.
|
||||
//
|
||||
// This function creates a circuit breaker that maintains its own internal state reference.
|
||||
// It's called "singleton" because it creates a single, self-contained circuit breaker instance
|
||||
// with its own IORef for state management. The returned function can be used to wrap
|
||||
// computations with circuit breaker protection.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - HKTT: The higher-kinded type representing the computation (e.g., IO[T], ReaderIO[R, T])
|
||||
//
|
||||
// Parameters:
|
||||
// - cb: The circuit breaker State monad created by MakeCircuitBreaker
|
||||
// - closedState: The initial closed state configuration for the circuit breaker
|
||||
//
|
||||
// Returns:
|
||||
// - A function that wraps a computation (HKTT) with circuit breaker logic.
|
||||
// The circuit breaker state is managed internally and persists across invocations.
|
||||
//
|
||||
// Thread Safety: The returned function is thread-safe. The internal IORef[BreakerState]
|
||||
// uses atomic operations to manage state. Multiple concurrent calls to the returned function
|
||||
// will be properly serialized at the state modification level.
|
||||
//
|
||||
// Example Usage:
|
||||
//
|
||||
// // Create a circuit breaker for IO operations
|
||||
// breaker := MakeSingletonBreaker(
|
||||
// MakeCircuitBreaker(...),
|
||||
// MakeClosedStateCounter(3),
|
||||
// )
|
||||
//
|
||||
// // Use it to wrap operations
|
||||
// protectedOp := breaker(myIOOperation)
|
||||
func MakeSingletonBreaker[HKTT any](
|
||||
cb State[Pair[IORef[BreakerState], HKTT], HKTT],
|
||||
closedState ClosedState,
|
||||
) func(HKTT) HKTT {
|
||||
return F.Flow3(
|
||||
F.Pipe3(
|
||||
closedState,
|
||||
MakeClosedIORef,
|
||||
io.Run,
|
||||
pair.FromHead[HKTT],
|
||||
),
|
||||
cb,
|
||||
pair.Tail,
|
||||
)
|
||||
}
|
||||
579
v2/circuitbreaker/circuitbreaker_test.go
Normal file
579
v2/circuitbreaker/circuitbreaker_test.go
Normal file
@@ -0,0 +1,579 @@
|
||||
package circuitbreaker
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/IBM/fp-go/v2/function"
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
"github.com/IBM/fp-go/v2/ioref"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
"github.com/IBM/fp-go/v2/retry"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type testMetrics struct {
|
||||
accepts int
|
||||
rejects int
|
||||
opens int
|
||||
closes int
|
||||
canary int
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (m *testMetrics) Accept(_ time.Time) IO[Void] {
|
||||
return func() Void {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.accepts++
|
||||
return function.VOID
|
||||
}
|
||||
}
|
||||
|
||||
func (m *testMetrics) Open(_ time.Time) IO[Void] {
|
||||
return func() Void {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.opens++
|
||||
return function.VOID
|
||||
}
|
||||
}
|
||||
|
||||
func (m *testMetrics) Close(_ time.Time) IO[Void] {
|
||||
return func() Void {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.closes++
|
||||
return function.VOID
|
||||
}
|
||||
}
|
||||
|
||||
func (m *testMetrics) Reject(_ time.Time) IO[Void] {
|
||||
return func() Void {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.rejects++
|
||||
return function.VOID
|
||||
}
|
||||
}
|
||||
|
||||
func (m *testMetrics) Canary(_ time.Time) IO[Void] {
|
||||
return func() Void {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.canary++
|
||||
return function.VOID
|
||||
}
|
||||
}
|
||||
|
||||
// VirtualTimer provides a controllable time source for testing
|
||||
type VirtualTimer struct {
|
||||
mu sync.Mutex
|
||||
current time.Time
|
||||
}
|
||||
|
||||
func NewMockMetrics() Metrics {
|
||||
return &testMetrics{}
|
||||
}
|
||||
|
||||
// NewVirtualTimer creates a new virtual timer starting at the given time
|
||||
func NewVirtualTimer(start time.Time) *VirtualTimer {
|
||||
return &VirtualTimer{current: start}
|
||||
}
|
||||
|
||||
// Now returns the current virtual time
|
||||
func (vt *VirtualTimer) Now() time.Time {
|
||||
vt.mu.Lock()
|
||||
defer vt.mu.Unlock()
|
||||
return vt.current
|
||||
}
|
||||
|
||||
// Advance moves the virtual time forward by the given duration
|
||||
func (vt *VirtualTimer) Advance(d time.Duration) {
|
||||
vt.mu.Lock()
|
||||
defer vt.mu.Unlock()
|
||||
vt.current = vt.current.Add(d)
|
||||
}
|
||||
|
||||
// Set sets the virtual time to a specific value
|
||||
func (vt *VirtualTimer) Set(t time.Time) {
|
||||
vt.mu.Lock()
|
||||
defer vt.mu.Unlock()
|
||||
vt.current = t
|
||||
}
|
||||
|
||||
// TestModifyV tests the modifyV variable
|
||||
func TestModifyV(t *testing.T) {
|
||||
t.Run("modifyV creates a Reader that modifies IORef", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
|
||||
// Create initial state
|
||||
initialState := createClosedCircuit(MakeClosedStateCounter(3))
|
||||
ref := io.Run(ioref.MakeIORef(initialState))
|
||||
|
||||
// Create an endomorphism that opens the circuit
|
||||
now := vt.Now()
|
||||
openState := openState{
|
||||
openedAt: now,
|
||||
resetAt: now.Add(1 * time.Minute),
|
||||
retryStatus: retry.DefaultRetryStatus,
|
||||
canaryRequest: false,
|
||||
}
|
||||
endomorphism := func(bs BreakerState) BreakerState {
|
||||
return createOpenCircuit(openState)
|
||||
}
|
||||
|
||||
// Apply modifyV
|
||||
modifyOp := modifyV(ref)
|
||||
result := io.Run(modifyOp(endomorphism))
|
||||
|
||||
// Verify the state was modified
|
||||
assert.True(t, IsOpen(result), "state should be open after modification")
|
||||
})
|
||||
|
||||
t.Run("modifyV returns the new state", func(t *testing.T) {
|
||||
initialState := createClosedCircuit(MakeClosedStateCounter(3))
|
||||
ref := io.Run(ioref.MakeIORef(initialState))
|
||||
|
||||
// Create a simple endomorphism
|
||||
endomorphism := F.Identity[BreakerState]
|
||||
|
||||
modifyOp := modifyV(ref)
|
||||
result := io.Run(modifyOp(endomorphism))
|
||||
|
||||
assert.True(t, IsClosed(result), "state should remain closed")
|
||||
})
|
||||
}
|
||||
|
||||
// TestTestCircuit tests the testCircuit variable
|
||||
func TestTestCircuit(t *testing.T) {
|
||||
t.Run("testCircuit sets canaryRequest to true", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
now := vt.Now()
|
||||
|
||||
openState := openState{
|
||||
openedAt: now,
|
||||
resetAt: now.Add(1 * time.Minute),
|
||||
retryStatus: retry.DefaultRetryStatus,
|
||||
canaryRequest: false,
|
||||
}
|
||||
|
||||
result := testCircuit(openState)
|
||||
|
||||
assert.True(t, result.canaryRequest, "canaryRequest should be set to true")
|
||||
assert.Equal(t, openState.openedAt, result.openedAt, "openedAt should be unchanged")
|
||||
assert.Equal(t, openState.resetAt, result.resetAt, "resetAt should be unchanged")
|
||||
})
|
||||
|
||||
t.Run("testCircuit is idempotent", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
now := vt.Now()
|
||||
|
||||
openState := openState{
|
||||
openedAt: now,
|
||||
resetAt: now.Add(1 * time.Minute),
|
||||
retryStatus: retry.DefaultRetryStatus,
|
||||
canaryRequest: true, // already true
|
||||
}
|
||||
|
||||
result := testCircuit(openState)
|
||||
|
||||
assert.True(t, result.canaryRequest, "canaryRequest should remain true")
|
||||
})
|
||||
|
||||
t.Run("testCircuit preserves other fields", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
now := vt.Now()
|
||||
resetTime := now.Add(2 * time.Minute)
|
||||
retryStatus := retry.RetryStatus{
|
||||
IterNumber: 5,
|
||||
PreviousDelay: option.Some(30 * time.Second),
|
||||
}
|
||||
|
||||
openState := openState{
|
||||
openedAt: now,
|
||||
resetAt: resetTime,
|
||||
retryStatus: retryStatus,
|
||||
canaryRequest: false,
|
||||
}
|
||||
|
||||
result := testCircuit(openState)
|
||||
|
||||
assert.Equal(t, now, result.openedAt, "openedAt should be preserved")
|
||||
assert.Equal(t, resetTime, result.resetAt, "resetAt should be preserved")
|
||||
assert.Equal(t, retryStatus.IterNumber, result.retryStatus.IterNumber, "retryStatus should be preserved")
|
||||
assert.True(t, result.canaryRequest, "canaryRequest should be set to true")
|
||||
})
|
||||
}
|
||||
|
||||
// TestMakeOpenCircuitFromPolicy tests the makeOpenCircuitFromPolicy function
|
||||
func TestMakeOpenCircuitFromPolicy(t *testing.T) {
|
||||
t.Run("creates openState with calculated reset time", func(t *testing.T) {
|
||||
policy := retry.LimitRetries(5)
|
||||
makeOpen := makeOpenCircuitFromPolicy(policy)
|
||||
|
||||
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
|
||||
result := makeOpen(retry.DefaultRetryStatus)(currentTime)
|
||||
|
||||
assert.Equal(t, currentTime, result.openedAt, "openedAt should be current time")
|
||||
assert.False(t, result.canaryRequest, "canaryRequest should be false initially")
|
||||
assert.NotNil(t, result.retryStatus, "retryStatus should be set")
|
||||
})
|
||||
|
||||
t.Run("applies retry policy to calculate delay", func(t *testing.T) {
|
||||
// Use exponential backoff policy with limit and cap
|
||||
policy := retry.Monoid.Concat(
|
||||
retry.LimitRetries(10),
|
||||
retry.CapDelay(10*time.Second, retry.ExponentialBackoff(1*time.Second)),
|
||||
)
|
||||
makeOpen := makeOpenCircuitFromPolicy(policy)
|
||||
|
||||
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// First retry (iter 0)
|
||||
result1 := makeOpen(retry.DefaultRetryStatus)(currentTime)
|
||||
|
||||
// The first delay should be approximately 1 second
|
||||
expectedResetTime1 := currentTime.Add(1 * time.Second)
|
||||
assert.WithinDuration(t, expectedResetTime1, result1.resetAt, 100*time.Millisecond,
|
||||
"first reset time should be ~1 second from now")
|
||||
|
||||
// Second retry (iter 1) - should double
|
||||
result2 := makeOpen(result1.retryStatus)(currentTime)
|
||||
expectedResetTime2 := currentTime.Add(2 * time.Second)
|
||||
assert.WithinDuration(t, expectedResetTime2, result2.resetAt, 100*time.Millisecond,
|
||||
"second reset time should be ~2 seconds from now")
|
||||
})
|
||||
|
||||
t.Run("handles first failure with no previous delay", func(t *testing.T) {
|
||||
policy := retry.LimitRetries(3)
|
||||
makeOpen := makeOpenCircuitFromPolicy(policy)
|
||||
|
||||
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
|
||||
result := makeOpen(retry.DefaultRetryStatus)(currentTime)
|
||||
|
||||
// With no previous delay, resetAt should be current time
|
||||
assert.Equal(t, currentTime, result.resetAt, "resetAt should be current time when no previous delay")
|
||||
})
|
||||
|
||||
t.Run("increments retry iteration number", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
policy := retry.LimitRetries(10)
|
||||
makeOpen := makeOpenCircuitFromPolicy(policy)
|
||||
|
||||
currentTime := vt.Now()
|
||||
initialStatus := retry.DefaultRetryStatus
|
||||
|
||||
result := makeOpen(initialStatus)(currentTime)
|
||||
|
||||
assert.Greater(t, result.retryStatus.IterNumber, initialStatus.IterNumber,
|
||||
"retry iteration should be incremented")
|
||||
})
|
||||
|
||||
t.Run("curried function can be partially applied", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
policy := retry.LimitRetries(5)
|
||||
makeOpen := makeOpenCircuitFromPolicy(policy)
|
||||
|
||||
// Partially apply with retry status
|
||||
makeOpenWithStatus := makeOpen(retry.DefaultRetryStatus)
|
||||
|
||||
currentTime := vt.Now()
|
||||
result := makeOpenWithStatus(currentTime)
|
||||
|
||||
assert.NotNil(t, result, "partially applied function should work")
|
||||
assert.Equal(t, currentTime, result.openedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// TestExtendOpenCircuitFromMakeCircuit tests the extendOpenCircuitFromMakeCircuit function
|
||||
func TestExtendOpenCircuitFromMakeCircuit(t *testing.T) {
|
||||
t.Run("extends open circuit with new retry status", func(t *testing.T) {
|
||||
policy := retry.Monoid.Concat(
|
||||
retry.LimitRetries(10),
|
||||
retry.ExponentialBackoff(1*time.Second),
|
||||
)
|
||||
makeCircuit := makeOpenCircuitFromPolicy(policy)
|
||||
extendCircuit := extendOpenCircuitFromMakeCircuit(makeCircuit)
|
||||
|
||||
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Create initial open state
|
||||
initialOpen := openState{
|
||||
openedAt: currentTime.Add(-1 * time.Minute),
|
||||
resetAt: currentTime,
|
||||
retryStatus: retry.DefaultRetryStatus,
|
||||
canaryRequest: false,
|
||||
}
|
||||
|
||||
// Extend the circuit
|
||||
extendOp := extendCircuit(currentTime)
|
||||
result := extendOp(initialOpen)
|
||||
|
||||
assert.True(t, result.canaryRequest, "canaryRequest should be set to true")
|
||||
assert.Greater(t, result.retryStatus.IterNumber, initialOpen.retryStatus.IterNumber,
|
||||
"retry iteration should be incremented")
|
||||
assert.True(t, result.resetAt.After(currentTime), "resetAt should be in the future")
|
||||
})
|
||||
|
||||
t.Run("sets canaryRequest to true for next test", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
policy := retry.LimitRetries(5)
|
||||
makeCircuit := makeOpenCircuitFromPolicy(policy)
|
||||
extendCircuit := extendOpenCircuitFromMakeCircuit(makeCircuit)
|
||||
|
||||
currentTime := vt.Now()
|
||||
initialOpen := openState{
|
||||
openedAt: currentTime.Add(-30 * time.Second),
|
||||
resetAt: currentTime,
|
||||
retryStatus: retry.DefaultRetryStatus,
|
||||
canaryRequest: false,
|
||||
}
|
||||
|
||||
result := extendCircuit(currentTime)(initialOpen)
|
||||
|
||||
assert.True(t, result.canaryRequest, "canaryRequest must be true after extension")
|
||||
})
|
||||
|
||||
t.Run("applies exponential backoff on successive extensions", func(t *testing.T) {
|
||||
policy := retry.Monoid.Concat(
|
||||
retry.LimitRetries(10),
|
||||
retry.ExponentialBackoff(1*time.Second),
|
||||
)
|
||||
makeCircuit := makeOpenCircuitFromPolicy(policy)
|
||||
extendCircuit := extendOpenCircuitFromMakeCircuit(makeCircuit)
|
||||
|
||||
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// First extension
|
||||
state1 := openState{
|
||||
openedAt: currentTime,
|
||||
resetAt: currentTime,
|
||||
retryStatus: retry.DefaultRetryStatus,
|
||||
canaryRequest: false,
|
||||
}
|
||||
result1 := extendCircuit(currentTime)(state1)
|
||||
delay1 := result1.resetAt.Sub(currentTime)
|
||||
|
||||
// Second extension (should have longer delay)
|
||||
result2 := extendCircuit(currentTime)(result1)
|
||||
delay2 := result2.resetAt.Sub(currentTime)
|
||||
|
||||
assert.Greater(t, delay2, delay1, "second extension should have longer delay due to exponential backoff")
|
||||
})
|
||||
}
|
||||
|
||||
// TestIsResetTimeExceeded tests the isResetTimeExceeded function
|
||||
func TestIsResetTimeExceeded(t *testing.T) {
|
||||
t.Run("returns Some when reset time is exceeded and no canary active", func(t *testing.T) {
|
||||
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
|
||||
resetTime := currentTime.Add(-1 * time.Second) // in the past
|
||||
|
||||
openState := openState{
|
||||
openedAt: currentTime.Add(-1 * time.Minute),
|
||||
resetAt: resetTime,
|
||||
retryStatus: retry.DefaultRetryStatus,
|
||||
canaryRequest: false,
|
||||
}
|
||||
|
||||
result := isResetTimeExceeded(currentTime)(openState)
|
||||
|
||||
assert.True(t, option.IsSome(result), "should return Some when reset time exceeded")
|
||||
})
|
||||
|
||||
t.Run("returns None when reset time not yet exceeded", func(t *testing.T) {
|
||||
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
|
||||
resetTime := currentTime.Add(1 * time.Minute) // in the future
|
||||
|
||||
openState := openState{
|
||||
openedAt: currentTime.Add(-30 * time.Second),
|
||||
resetAt: resetTime,
|
||||
retryStatus: retry.DefaultRetryStatus,
|
||||
canaryRequest: false,
|
||||
}
|
||||
|
||||
result := isResetTimeExceeded(currentTime)(openState)
|
||||
|
||||
assert.True(t, option.IsNone(result), "should return None when reset time not exceeded")
|
||||
})
|
||||
|
||||
t.Run("returns None when canary request is already active", func(t *testing.T) {
|
||||
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
|
||||
resetTime := currentTime.Add(-1 * time.Second) // in the past
|
||||
|
||||
openState := openState{
|
||||
openedAt: currentTime.Add(-1 * time.Minute),
|
||||
resetAt: resetTime,
|
||||
retryStatus: retry.DefaultRetryStatus,
|
||||
canaryRequest: true, // canary already active
|
||||
}
|
||||
|
||||
result := isResetTimeExceeded(currentTime)(openState)
|
||||
|
||||
assert.True(t, option.IsNone(result), "should return None when canary is already active")
|
||||
})
|
||||
|
||||
t.Run("returns Some at exact reset time boundary", func(t *testing.T) {
|
||||
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
|
||||
resetTime := currentTime.Add(-1 * time.Nanosecond) // just passed
|
||||
|
||||
openState := openState{
|
||||
openedAt: currentTime.Add(-1 * time.Minute),
|
||||
resetAt: resetTime,
|
||||
retryStatus: retry.DefaultRetryStatus,
|
||||
canaryRequest: false,
|
||||
}
|
||||
|
||||
result := isResetTimeExceeded(currentTime)(openState)
|
||||
|
||||
assert.True(t, option.IsSome(result), "should return Some when current time is after reset time")
|
||||
})
|
||||
|
||||
t.Run("returns None when current time equals reset time", func(t *testing.T) {
|
||||
currentTime := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
|
||||
resetTime := currentTime // exactly equal
|
||||
|
||||
openState := openState{
|
||||
openedAt: currentTime.Add(-1 * time.Minute),
|
||||
resetAt: resetTime,
|
||||
retryStatus: retry.DefaultRetryStatus,
|
||||
canaryRequest: false,
|
||||
}
|
||||
|
||||
result := isResetTimeExceeded(currentTime)(openState)
|
||||
|
||||
assert.True(t, option.IsNone(result), "should return None when times are equal (not After)")
|
||||
})
|
||||
}
|
||||
|
||||
// TestHandleSuccessOnClosed tests the handleSuccessOnClosed function
|
||||
func TestHandleSuccessOnClosed(t *testing.T) {
|
||||
t.Run("resets failure count on success", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
currentTime := vt.Now
|
||||
addSuccess := reader.From1(ClosedState.AddSuccess)
|
||||
|
||||
// Create initial state with some failures
|
||||
now := vt.Now()
|
||||
initialClosed := MakeClosedStateCounter(3)
|
||||
initialClosed = initialClosed.AddError(now)
|
||||
initialClosed = initialClosed.AddError(now)
|
||||
initialState := createClosedCircuit(initialClosed)
|
||||
|
||||
ref := io.Run(ioref.MakeIORef(initialState))
|
||||
modify := modifyV(ref)
|
||||
|
||||
handler := handleSuccessOnClosed(currentTime, addSuccess)
|
||||
|
||||
// Apply the handler
|
||||
result := io.Run(handler(modify))
|
||||
|
||||
// Verify state is still closed and failures are reset
|
||||
assert.True(t, IsClosed(result), "circuit should remain closed after success")
|
||||
})
|
||||
|
||||
t.Run("keeps circuit closed", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
currentTime := vt.Now
|
||||
addSuccess := reader.From1(ClosedState.AddSuccess)
|
||||
|
||||
initialState := createClosedCircuit(MakeClosedStateCounter(3))
|
||||
ref := io.Run(ioref.MakeIORef(initialState))
|
||||
modify := modifyV(ref)
|
||||
|
||||
handler := handleSuccessOnClosed(currentTime, addSuccess)
|
||||
result := io.Run(handler(modify))
|
||||
|
||||
assert.True(t, IsClosed(result), "circuit should remain closed")
|
||||
})
|
||||
}
|
||||
|
||||
// TestHandleFailureOnClosed tests the handleFailureOnClosed function
|
||||
func TestHandleFailureOnClosed(t *testing.T) {
|
||||
t.Run("keeps circuit closed when threshold not exceeded", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
currentTime := vt.Now
|
||||
addError := reader.From1(ClosedState.AddError)
|
||||
checkClosedState := reader.From1(ClosedState.Check)
|
||||
openCircuit := func(ct time.Time) openState {
|
||||
return openState{
|
||||
openedAt: ct,
|
||||
resetAt: ct.Add(1 * time.Minute),
|
||||
retryStatus: retry.DefaultRetryStatus,
|
||||
canaryRequest: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Create initial state with room for more failures
|
||||
now := vt.Now()
|
||||
initialClosed := MakeClosedStateCounter(5) // threshold is 5
|
||||
initialClosed = initialClosed.AddError(now)
|
||||
initialState := createClosedCircuit(initialClosed)
|
||||
|
||||
ref := io.Run(ioref.MakeIORef(initialState))
|
||||
modify := modifyV(ref)
|
||||
|
||||
handler := handleFailureOnClosed(currentTime, addError, checkClosedState, openCircuit)
|
||||
result := io.Run(handler(modify))
|
||||
|
||||
assert.True(t, IsClosed(result), "circuit should remain closed when threshold not exceeded")
|
||||
})
|
||||
|
||||
t.Run("opens circuit when threshold exceeded", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
currentTime := vt.Now
|
||||
addError := reader.From1(ClosedState.AddError)
|
||||
checkClosedState := reader.From1(ClosedState.Check)
|
||||
openCircuit := func(ct time.Time) openState {
|
||||
return openState{
|
||||
openedAt: ct,
|
||||
resetAt: ct.Add(1 * time.Minute),
|
||||
retryStatus: retry.DefaultRetryStatus,
|
||||
canaryRequest: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Create initial state at threshold
|
||||
now := vt.Now()
|
||||
initialClosed := MakeClosedStateCounter(2) // threshold is 2
|
||||
initialClosed = initialClosed.AddError(now)
|
||||
initialState := createClosedCircuit(initialClosed)
|
||||
|
||||
ref := io.Run(ioref.MakeIORef(initialState))
|
||||
modify := modifyV(ref)
|
||||
|
||||
handler := handleFailureOnClosed(currentTime, addError, checkClosedState, openCircuit)
|
||||
result := io.Run(handler(modify))
|
||||
|
||||
assert.True(t, IsOpen(result), "circuit should open when threshold exceeded")
|
||||
})
|
||||
|
||||
t.Run("records failure in closed state", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
currentTime := vt.Now
|
||||
addError := reader.From1(ClosedState.AddError)
|
||||
checkClosedState := reader.From1(ClosedState.Check)
|
||||
openCircuit := func(ct time.Time) openState {
|
||||
return openState{
|
||||
openedAt: ct,
|
||||
resetAt: ct.Add(1 * time.Minute),
|
||||
retryStatus: retry.DefaultRetryStatus,
|
||||
canaryRequest: false,
|
||||
}
|
||||
}
|
||||
|
||||
initialState := createClosedCircuit(MakeClosedStateCounter(10))
|
||||
ref := io.Run(ioref.MakeIORef(initialState))
|
||||
modify := modifyV(ref)
|
||||
|
||||
handler := handleFailureOnClosed(currentTime, addError, checkClosedState, openCircuit)
|
||||
result := io.Run(handler(modify))
|
||||
|
||||
// Should still be closed but with failure recorded
|
||||
assert.True(t, IsClosed(result), "circuit should remain closed")
|
||||
})
|
||||
}
|
||||
329
v2/circuitbreaker/closed.go
Normal file
329
v2/circuitbreaker/closed.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package circuitbreaker
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
A "github.com/IBM/fp-go/v2/array"
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
N "github.com/IBM/fp-go/v2/number"
|
||||
"github.com/IBM/fp-go/v2/optics/lens"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/ord"
|
||||
)
|
||||
|
||||
type (
|
||||
// ClosedState represents the closed state of a circuit breaker.
|
||||
// In the closed state, requests are allowed to pass through, but failures are tracked.
|
||||
// If a failure condition is met, the circuit breaker transitions to an open state.
|
||||
//
|
||||
// # Thread Safety
|
||||
//
|
||||
// All ClosedState implementations MUST be thread-safe. The recommended approach is to
|
||||
// make all methods return new copies rather than modifying the receiver, which provides
|
||||
// automatic thread safety through immutability.
|
||||
//
|
||||
// Implementations should ensure that:
|
||||
// - Empty() returns a new instance with cleared state
|
||||
// - AddError() returns a new instance with the error recorded
|
||||
// - AddSuccess() returns a new instance with success recorded
|
||||
// - Check() does not modify the receiver
|
||||
//
|
||||
// Both provided implementations (closedStateWithErrorCount and closedStateWithHistory)
|
||||
// follow this pattern and are safe for concurrent use.
|
||||
ClosedState interface {
|
||||
// Empty returns a new ClosedState with all tracked failures cleared.
|
||||
// This is used when transitioning back to a closed state from an open state.
|
||||
//
|
||||
// Thread Safety: Returns a new instance; safe for concurrent use.
|
||||
Empty() ClosedState
|
||||
|
||||
// AddError records a failure at the given time.
|
||||
// Returns an updated ClosedState reflecting the recorded failure.
|
||||
//
|
||||
// Thread Safety: Returns a new instance; safe for concurrent use.
|
||||
// The original ClosedState is not modified.
|
||||
AddError(time.Time) ClosedState
|
||||
|
||||
// AddSuccess records a successful request at the given time.
|
||||
// Returns an updated ClosedState reflecting the successful request.
|
||||
//
|
||||
// Thread Safety: Returns a new instance; safe for concurrent use.
|
||||
// The original ClosedState is not modified.
|
||||
AddSuccess(time.Time) ClosedState
|
||||
|
||||
// Check verifies if the circuit breaker should remain closed at the given time.
|
||||
// Returns Some(ClosedState) if the circuit should stay closed,
|
||||
// or None if the circuit should open due to exceeding the failure threshold.
|
||||
//
|
||||
// Thread Safety: Does not modify the receiver; safe for concurrent use.
|
||||
Check(time.Time) Option[ClosedState]
|
||||
}
|
||||
|
||||
// closedStateWithErrorCount is a counter-based implementation of ClosedState.
|
||||
// It tracks the number of consecutive failures and opens the circuit when
|
||||
// the failure count exceeds a configured threshold.
|
||||
//
|
||||
// Thread Safety: This implementation is immutable. All methods return new instances
|
||||
// rather than modifying the receiver, making it safe for concurrent use without locks.
|
||||
closedStateWithErrorCount struct {
|
||||
// checkFailures is a Kleisli arrow that checks if the failure count exceeds the threshold.
|
||||
// Returns Some(count) if threshold is exceeded, None otherwise.
|
||||
checkFailures option.Kleisli[uint, uint]
|
||||
// failureCount tracks the current number of consecutive failures.
|
||||
failureCount uint
|
||||
}
|
||||
|
||||
// closedStateWithHistory is a time-window-based implementation of ClosedState.
|
||||
// It tracks failures within a sliding time window and opens the circuit when
|
||||
// the failure count within the window exceeds a configured threshold.
|
||||
//
|
||||
// Thread Safety: This implementation is immutable. All methods return new instances
|
||||
// with new slices rather than modifying the receiver, making it safe for concurrent
|
||||
// use without locks. The history slice is never modified in place; addToSlice always
|
||||
// creates a new slice.
|
||||
closedStateWithHistory struct {
|
||||
ordTime Ord[time.Time]
|
||||
// maxFailures is the maximum number of failures allowed within the time window.
|
||||
checkFailures option.Kleisli[int, int]
|
||||
timeWindow time.Duration
|
||||
history []time.Time
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
failureCountLens = lens.MakeLensStrictWithName(
|
||||
func(s *closedStateWithErrorCount) uint { return s.failureCount },
|
||||
func(s *closedStateWithErrorCount, c uint) *closedStateWithErrorCount {
|
||||
s.failureCount = c
|
||||
return s
|
||||
},
|
||||
"closeStateWithErrorCount.failureCount",
|
||||
)
|
||||
|
||||
historyLens = lens.MakeLensRefWithName(
|
||||
func(s *closedStateWithHistory) []time.Time { return s.history },
|
||||
func(s *closedStateWithHistory, c []time.Time) *closedStateWithHistory {
|
||||
s.history = c
|
||||
return s
|
||||
},
|
||||
"closedStateWithHistory.history",
|
||||
)
|
||||
|
||||
resetHistory = historyLens.Set(A.Empty[time.Time]())
|
||||
resetFailureCount = failureCountLens.Set(0)
|
||||
incFailureCount = lens.Modify[*closedStateWithErrorCount](N.Add(uint(1)))(failureCountLens)
|
||||
)
|
||||
|
||||
// Empty returns a new closedStateWithErrorCount with the failure count reset to zero.
|
||||
//
|
||||
// Thread Safety: Returns a new instance; the original is not modified.
|
||||
// Safe for concurrent use.
|
||||
func (s *closedStateWithErrorCount) Empty() ClosedState {
|
||||
return resetFailureCount(s)
|
||||
}
|
||||
|
||||
// AddError increments the failure count and returns a new closedStateWithErrorCount.
|
||||
// The time parameter is ignored in this counter-based implementation.
|
||||
//
|
||||
// Thread Safety: Returns a new instance; the original is not modified.
|
||||
// Safe for concurrent use.
|
||||
func (s *closedStateWithErrorCount) AddError(_ time.Time) ClosedState {
|
||||
return incFailureCount(s)
|
||||
}
|
||||
|
||||
// AddSuccess resets the failure count to zero and returns a new closedStateWithErrorCount.
|
||||
// The time parameter is ignored in this counter-based implementation.
|
||||
//
|
||||
// Thread Safety: Returns a new instance; the original is not modified.
|
||||
// Safe for concurrent use.
|
||||
func (s *closedStateWithErrorCount) AddSuccess(_ time.Time) ClosedState {
|
||||
return resetFailureCount(s)
|
||||
}
|
||||
|
||||
// Check verifies if the failure count is below the threshold.
|
||||
// Returns Some(ClosedState) if below threshold, None if at or above threshold.
|
||||
// The time parameter is ignored in this counter-based implementation.
|
||||
//
|
||||
// Thread Safety: Does not modify the receiver; safe for concurrent use.
|
||||
func (s *closedStateWithErrorCount) Check(_ time.Time) Option[ClosedState] {
|
||||
return F.Pipe3(
|
||||
s,
|
||||
failureCountLens.Get,
|
||||
s.checkFailures,
|
||||
option.MapTo[uint](ClosedState(s)),
|
||||
)
|
||||
}
|
||||
|
||||
// MakeClosedStateCounter creates a counter-based ClosedState implementation.
|
||||
// The circuit breaker will open when the number of consecutive failures reaches maxFailures.
|
||||
//
|
||||
// Parameters:
|
||||
// - maxFailures: The threshold for consecutive failures. The circuit opens when
|
||||
// failureCount >= maxFailures (greater than or equal to).
|
||||
//
|
||||
// Returns:
|
||||
// - A ClosedState that tracks failures using a simple counter.
|
||||
//
|
||||
// Example:
|
||||
// - If maxFailures is 3, the circuit will open on the 3rd consecutive failure.
|
||||
// - Each AddError call increments the counter.
|
||||
// - Each AddSuccess call resets the counter to 0 (only consecutive failures count).
|
||||
// - Empty resets the counter to 0.
|
||||
//
|
||||
// Behavior:
|
||||
// - Check returns Some(ClosedState) when failureCount < maxFailures (circuit stays closed)
|
||||
// - Check returns None when failureCount >= maxFailures (circuit should open)
|
||||
// - AddSuccess resets the failure count, so only consecutive failures trigger circuit opening
|
||||
//
|
||||
// Thread Safety: The returned ClosedState is safe for concurrent use. All methods
|
||||
// return new instances rather than modifying the receiver.
|
||||
func MakeClosedStateCounter(maxFailures uint) ClosedState {
|
||||
return &closedStateWithErrorCount{
|
||||
checkFailures: option.FromPredicate(N.LessThan(maxFailures)),
|
||||
}
|
||||
}
|
||||
|
||||
// Empty returns a new closedStateWithHistory with an empty failure history.
|
||||
//
|
||||
// Thread Safety: Returns a new instance with a new empty slice; the original is not modified.
|
||||
// Safe for concurrent use.
|
||||
func (s *closedStateWithHistory) Empty() ClosedState {
|
||||
return resetHistory(s)
|
||||
}
|
||||
|
||||
// addToSlice creates a new sorted slice by adding an item to an existing slice.
|
||||
// This function does not modify the input slice; it creates a new slice with the item added
|
||||
// and returns it in sorted order.
|
||||
//
|
||||
// Parameters:
|
||||
// - o: An Ord instance for comparing time.Time values to determine sort order
|
||||
// - ar: The existing slice of time.Time values (assumed to be sorted)
|
||||
// - item: The new time.Time value to add to the slice
|
||||
//
|
||||
// Returns:
|
||||
// - A new slice containing all elements from ar plus the new item, sorted in ascending order
|
||||
//
|
||||
// Implementation Details:
|
||||
// - Creates a new slice with capacity len(ar)+1
|
||||
// - Copies all elements from ar to the new slice
|
||||
// - Appends the new item
|
||||
// - Sorts the entire slice using the provided Ord comparator
|
||||
//
|
||||
// Thread Safety: This function is pure and does not modify its inputs. It always returns
|
||||
// a new slice, making it safe for concurrent use. This is a key component of the immutable
|
||||
// design of closedStateWithHistory.
|
||||
//
|
||||
// Note: This function is used internally by closedStateWithHistory.AddError to maintain
|
||||
// a sorted history of failure timestamps for efficient binary search operations.
|
||||
func addToSlice(o ord.Ord[time.Time], ar []time.Time, item time.Time) []time.Time {
|
||||
cpy := make([]time.Time, len(ar)+1)
|
||||
cpy[copy(cpy, ar)] = item
|
||||
slices.SortFunc(cpy, o.Compare)
|
||||
return cpy
|
||||
}
|
||||
|
||||
// AddError records a failure at the given time and returns a new closedStateWithHistory.
|
||||
// The new instance contains the failure in its history, with old failures outside the
|
||||
// time window automatically pruned.
|
||||
//
|
||||
// Thread Safety: Returns a new instance with a new history slice; the original is not modified.
|
||||
// Safe for concurrent use. The addToSlice function creates a new slice, ensuring immutability.
|
||||
func (s *closedStateWithHistory) AddError(currentTime time.Time) ClosedState {
|
||||
|
||||
addFailureToHistory := F.Pipe1(
|
||||
historyLens,
|
||||
lens.Modify[*closedStateWithHistory](func(old []time.Time) []time.Time {
|
||||
// oldest valid entry
|
||||
idx, _ := slices.BinarySearchFunc(old, currentTime.Add(-s.timeWindow), s.ordTime.Compare)
|
||||
return addToSlice(s.ordTime, old[idx:], currentTime)
|
||||
}),
|
||||
)
|
||||
|
||||
return addFailureToHistory(s)
|
||||
}
|
||||
|
||||
// AddSuccess purges the entire failure history and returns a new closedStateWithHistory.
|
||||
// The time parameter is ignored; any success clears all tracked failures.
|
||||
//
|
||||
// Thread Safety: Returns a new instance with a new empty slice; the original is not modified.
|
||||
// Safe for concurrent use.
|
||||
func (s *closedStateWithHistory) AddSuccess(_ time.Time) ClosedState {
|
||||
return resetHistory(s)
|
||||
}
|
||||
|
||||
// Check verifies if the number of failures in the history is below the threshold.
|
||||
// Returns Some(ClosedState) if below threshold, None if at or above threshold.
|
||||
// The time parameter is ignored; the check is based on the current history size.
|
||||
//
|
||||
// Thread Safety: Does not modify the receiver; safe for concurrent use.
|
||||
func (s *closedStateWithHistory) Check(_ time.Time) Option[ClosedState] {
|
||||
|
||||
return F.Pipe4(
|
||||
s,
|
||||
historyLens.Get,
|
||||
A.Size,
|
||||
s.checkFailures,
|
||||
option.MapTo[int](ClosedState(s)),
|
||||
)
|
||||
}
|
||||
|
||||
// MakeClosedStateHistory creates a time-window-based ClosedState implementation.
|
||||
// The circuit breaker will open when the number of failures within a sliding time window reaches maxFailures.
|
||||
//
|
||||
// Unlike MakeClosedStateCounter which tracks consecutive failures, this implementation tracks
|
||||
// all failures within a time window. However, any successful request will purge the entire history,
|
||||
// effectively resetting the failure tracking.
|
||||
//
|
||||
// Parameters:
|
||||
// - timeWindow: The duration of the sliding time window. Failures older than this are automatically
|
||||
// discarded from the history when new failures are added.
|
||||
// - maxFailures: The threshold for failures within the time window. The circuit opens when
|
||||
// the number of failures in the window reaches this value (failureCount >= maxFailures).
|
||||
//
|
||||
// Returns:
|
||||
// - A ClosedState that tracks failures using a time-based sliding window.
|
||||
//
|
||||
// Example:
|
||||
// - If timeWindow is 1 minute and maxFailures is 5, the circuit will open when 5 failures
|
||||
// occur within any 1-minute period.
|
||||
// - Failures older than 1 minute are automatically removed from the history when AddError is called.
|
||||
// - Any successful request immediately purges all tracked failures from the history.
|
||||
//
|
||||
// Behavior:
|
||||
// - AddError records the failure timestamp and removes failures outside the time window
|
||||
// (older than currentTime - timeWindow).
|
||||
// - AddSuccess purges the entire failure history (all tracked failures are removed).
|
||||
// - Check returns Some(ClosedState) when failureCount < maxFailures (circuit stays closed).
|
||||
// - Check returns None when failureCount >= maxFailures (circuit should open).
|
||||
// - Empty purges the entire failure history.
|
||||
//
|
||||
// Time Window Management:
|
||||
// - The history is automatically pruned on each AddError call to remove failures older than
|
||||
// currentTime - timeWindow.
|
||||
// - The history is kept sorted by time for efficient binary search and pruning.
|
||||
//
|
||||
// Important Note:
|
||||
// - A successful request resets everything by purging the entire history. This means that
|
||||
// unlike a pure sliding window, a single success will clear all tracked failures, even
|
||||
// those within the time window. This behavior is similar to MakeClosedStateCounter but
|
||||
// with time-based tracking for failures.
|
||||
//
|
||||
// Thread Safety: The returned ClosedState is safe for concurrent use. All methods return
|
||||
// new instances with new slices rather than modifying the receiver. The history slice is
|
||||
// never modified in place.
|
||||
//
|
||||
// Use Cases:
|
||||
// - Systems where a successful request indicates recovery and past failures should be forgotten.
|
||||
// - Rate limiting with success-based reset: Allow bursts of failures but reset on success.
|
||||
// - Hybrid approach: Time-based failure tracking with success-based recovery.
|
||||
func MakeClosedStateHistory(
|
||||
timeWindow time.Duration,
|
||||
maxFailures uint) ClosedState {
|
||||
return &closedStateWithHistory{
|
||||
checkFailures: option.FromPredicate(N.LessThan(int(maxFailures))),
|
||||
ordTime: ord.OrdTime(),
|
||||
history: A.Empty[time.Time](),
|
||||
timeWindow: timeWindow,
|
||||
}
|
||||
}
|
||||
934
v2/circuitbreaker/closed_test.go
Normal file
934
v2/circuitbreaker/closed_test.go
Normal file
@@ -0,0 +1,934 @@
|
||||
package circuitbreaker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/ord"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMakeClosedStateCounter(t *testing.T) {
|
||||
t.Run("creates a valid ClosedState", func(t *testing.T) {
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateCounter(maxFailures)
|
||||
|
||||
assert.NotNil(t, state, "MakeClosedStateCounter should return a non-nil ClosedState")
|
||||
})
|
||||
|
||||
t.Run("initial state passes Check", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateCounter(maxFailures)
|
||||
now := vt.Now()
|
||||
|
||||
result := state.Check(now)
|
||||
|
||||
assert.True(t, option.IsSome(result), "initial state should pass Check (return Some, circuit stays closed)")
|
||||
})
|
||||
|
||||
t.Run("Empty resets failure count", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
maxFailures := uint(2)
|
||||
state := MakeClosedStateCounter(maxFailures)
|
||||
now := vt.Now()
|
||||
|
||||
// Add some errors
|
||||
state = state.AddError(now)
|
||||
state = state.AddError(now)
|
||||
|
||||
// Reset the state
|
||||
state = state.Empty()
|
||||
|
||||
// Should pass check after reset
|
||||
result := state.Check(now)
|
||||
assert.True(t, option.IsSome(result), "state should pass Check after Empty")
|
||||
})
|
||||
|
||||
t.Run("AddSuccess resets failure count", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateCounter(maxFailures)
|
||||
|
||||
// Add errors
|
||||
state = state.AddError(vt.Now())
|
||||
vt.Advance(1 * time.Second)
|
||||
state = state.AddError(vt.Now())
|
||||
vt.Advance(1 * time.Second)
|
||||
|
||||
// Add success (should reset counter)
|
||||
state = state.AddSuccess(vt.Now())
|
||||
vt.Advance(1 * time.Second)
|
||||
|
||||
// Add another error (this is now the first consecutive error)
|
||||
state = state.AddError(vt.Now())
|
||||
|
||||
// Should still pass check (only 1 consecutive error, threshold is 3)
|
||||
result := state.Check(vt.Now())
|
||||
assert.True(t, option.IsSome(result), "AddSuccess should reset failure count")
|
||||
})
|
||||
|
||||
t.Run("circuit opens when failures reach threshold", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateCounter(maxFailures)
|
||||
now := vt.Now()
|
||||
|
||||
// Add errors up to but not including threshold
|
||||
state = state.AddError(now)
|
||||
state = state.AddError(now)
|
||||
|
||||
// Should still pass before threshold
|
||||
result := state.Check(now)
|
||||
assert.True(t, option.IsSome(result), "should pass Check before threshold")
|
||||
|
||||
// Add one more error to reach threshold
|
||||
state = state.AddError(now)
|
||||
|
||||
// Should fail check at threshold
|
||||
result = state.Check(now)
|
||||
assert.True(t, option.IsNone(result), "should fail Check when reaching threshold")
|
||||
})
|
||||
|
||||
t.Run("circuit opens exactly at maxFailures", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
maxFailures := uint(5)
|
||||
state := MakeClosedStateCounter(maxFailures)
|
||||
now := vt.Now()
|
||||
|
||||
// Add exactly maxFailures - 1 errors
|
||||
for i := uint(0); i < maxFailures-1; i++ {
|
||||
state = state.AddError(now)
|
||||
}
|
||||
|
||||
// Should still pass
|
||||
result := state.Check(now)
|
||||
assert.True(t, option.IsSome(result), "should pass Check before maxFailures")
|
||||
|
||||
// Add one more to reach maxFailures
|
||||
state = state.AddError(now)
|
||||
|
||||
// Should fail now
|
||||
result = state.Check(now)
|
||||
assert.True(t, option.IsNone(result), "should fail Check at maxFailures")
|
||||
})
|
||||
|
||||
t.Run("zero maxFailures means circuit is always open", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
maxFailures := uint(0)
|
||||
state := MakeClosedStateCounter(maxFailures)
|
||||
now := vt.Now()
|
||||
|
||||
// Initial state should already fail (0 >= 0)
|
||||
result := state.Check(now)
|
||||
assert.True(t, option.IsNone(result), "initial state should fail Check with maxFailures=0")
|
||||
|
||||
// Add one error
|
||||
state = state.AddError(now)
|
||||
|
||||
// Should still fail
|
||||
result = state.Check(now)
|
||||
assert.True(t, option.IsNone(result), "should fail Check after error with maxFailures=0")
|
||||
})
|
||||
|
||||
t.Run("AddSuccess resets counter between errors", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateCounter(maxFailures)
|
||||
|
||||
// Add errors
|
||||
state = state.AddError(vt.Now())
|
||||
vt.Advance(1 * time.Second)
|
||||
state = state.AddError(vt.Now())
|
||||
vt.Advance(1 * time.Second)
|
||||
|
||||
// Add success (resets counter)
|
||||
state = state.AddSuccess(vt.Now())
|
||||
vt.Advance(1 * time.Second)
|
||||
|
||||
// Add more errors
|
||||
state = state.AddError(vt.Now())
|
||||
vt.Advance(1 * time.Second)
|
||||
state = state.AddError(vt.Now())
|
||||
|
||||
// Should still pass (only 2 consecutive errors after reset)
|
||||
result := state.Check(vt.Now())
|
||||
assert.True(t, option.IsSome(result), "should pass with 2 consecutive errors after reset")
|
||||
|
||||
// Add one more to reach threshold
|
||||
vt.Advance(1 * time.Second)
|
||||
state = state.AddError(vt.Now())
|
||||
|
||||
// Should fail at threshold
|
||||
result = state.Check(vt.Now())
|
||||
assert.True(t, option.IsNone(result), "should fail after reaching threshold")
|
||||
})
|
||||
|
||||
t.Run("Empty can be called multiple times", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
maxFailures := uint(2)
|
||||
state := MakeClosedStateCounter(maxFailures)
|
||||
now := vt.Now()
|
||||
|
||||
// Add errors
|
||||
state = state.AddError(now)
|
||||
state = state.AddError(now)
|
||||
state = state.AddError(now)
|
||||
|
||||
// Reset multiple times
|
||||
state = state.Empty()
|
||||
state = state.Empty()
|
||||
state = state.Empty()
|
||||
|
||||
// Should still pass
|
||||
result := state.Check(now)
|
||||
assert.True(t, option.IsSome(result), "state should pass Check after multiple Empty calls")
|
||||
})
|
||||
|
||||
t.Run("time parameter is ignored in counter implementation", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateCounter(maxFailures)
|
||||
|
||||
// Use different times for each operation
|
||||
time1 := vt.Now()
|
||||
time2 := time1.Add(1 * time.Hour)
|
||||
|
||||
state = state.AddError(time1)
|
||||
state = state.AddError(time2)
|
||||
|
||||
// Check with yet another time
|
||||
time3 := time1.Add(2 * time.Hour)
|
||||
result := state.Check(time3)
|
||||
|
||||
// Should still pass (2 errors, threshold is 3, not reached yet)
|
||||
assert.True(t, option.IsSome(result), "time parameter should not affect counter behavior")
|
||||
|
||||
// Add one more to reach threshold
|
||||
state = state.AddError(time1)
|
||||
result = state.Check(time1)
|
||||
assert.True(t, option.IsNone(result), "should fail after reaching threshold regardless of time")
|
||||
})
|
||||
|
||||
t.Run("large maxFailures value", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
maxFailures := uint(1000)
|
||||
state := MakeClosedStateCounter(maxFailures)
|
||||
now := vt.Now()
|
||||
|
||||
// Add many errors but not reaching threshold
|
||||
for i := uint(0); i < maxFailures-1; i++ {
|
||||
state = state.AddError(now)
|
||||
}
|
||||
|
||||
// Should still pass
|
||||
result := state.Check(now)
|
||||
assert.True(t, option.IsSome(result), "should pass Check with large maxFailures before threshold")
|
||||
|
||||
// Add one more to reach threshold
|
||||
state = state.AddError(now)
|
||||
|
||||
// Should fail
|
||||
result = state.Check(now)
|
||||
assert.True(t, option.IsNone(result), "should fail Check with large maxFailures at threshold")
|
||||
})
|
||||
|
||||
t.Run("state is immutable - original unchanged after AddError", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
maxFailures := uint(2)
|
||||
originalState := MakeClosedStateCounter(maxFailures)
|
||||
now := vt.Now()
|
||||
|
||||
// Create new state by adding error
|
||||
newState := originalState.AddError(now)
|
||||
|
||||
// Original should still pass check
|
||||
result := originalState.Check(now)
|
||||
assert.True(t, option.IsSome(result), "original state should be unchanged")
|
||||
|
||||
// New state should reach threshold (2 errors total, threshold is 2)
|
||||
newState = newState.AddError(now)
|
||||
|
||||
result = newState.Check(now)
|
||||
assert.True(t, option.IsNone(result), "new state should fail after reaching threshold")
|
||||
|
||||
// Original should still pass
|
||||
result = originalState.Check(now)
|
||||
assert.True(t, option.IsSome(result), "original state should still be unchanged")
|
||||
})
|
||||
|
||||
t.Run("state is immutable - original unchanged after Empty", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
maxFailures := uint(2)
|
||||
state := MakeClosedStateCounter(maxFailures)
|
||||
now := vt.Now()
|
||||
|
||||
// Add errors to original
|
||||
state = state.AddError(now)
|
||||
state = state.AddError(now)
|
||||
stateWithErrors := state
|
||||
|
||||
// Create new state by calling Empty
|
||||
emptyState := stateWithErrors.Empty()
|
||||
|
||||
// Original with errors should reach threshold (2 errors total, threshold is 2)
|
||||
result := stateWithErrors.Check(now)
|
||||
assert.True(t, option.IsNone(result), "state with errors should fail after reaching threshold")
|
||||
|
||||
// Empty state should pass
|
||||
result = emptyState.Check(now)
|
||||
assert.True(t, option.IsSome(result), "empty state should pass Check")
|
||||
})
|
||||
|
||||
t.Run("AddSuccess prevents circuit from opening", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateCounter(maxFailures)
|
||||
|
||||
// Add errors close to threshold
|
||||
state = state.AddError(vt.Now())
|
||||
vt.Advance(1 * time.Second)
|
||||
state = state.AddError(vt.Now())
|
||||
vt.Advance(1 * time.Second)
|
||||
|
||||
// Add success before reaching threshold
|
||||
state = state.AddSuccess(vt.Now())
|
||||
vt.Advance(1 * time.Second)
|
||||
|
||||
// Add more errors
|
||||
state = state.AddError(vt.Now())
|
||||
vt.Advance(1 * time.Second)
|
||||
state = state.AddError(vt.Now())
|
||||
|
||||
// Should still pass (only 2 consecutive errors)
|
||||
result := state.Check(vt.Now())
|
||||
assert.True(t, option.IsSome(result), "circuit should stay closed after success reset")
|
||||
})
|
||||
|
||||
t.Run("multiple AddSuccess calls keep counter at zero", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
maxFailures := uint(2)
|
||||
state := MakeClosedStateCounter(maxFailures)
|
||||
|
||||
// Add error
|
||||
state = state.AddError(vt.Now())
|
||||
vt.Advance(1 * time.Second)
|
||||
|
||||
// Multiple successes
|
||||
state = state.AddSuccess(vt.Now())
|
||||
vt.Advance(1 * time.Second)
|
||||
state = state.AddSuccess(vt.Now())
|
||||
vt.Advance(1 * time.Second)
|
||||
state = state.AddSuccess(vt.Now())
|
||||
vt.Advance(1 * time.Second)
|
||||
|
||||
// Should still pass
|
||||
result := state.Check(vt.Now())
|
||||
assert.True(t, option.IsSome(result), "multiple AddSuccess should keep counter at zero")
|
||||
|
||||
// Add errors to reach threshold
|
||||
state = state.AddError(vt.Now())
|
||||
vt.Advance(1 * time.Second)
|
||||
state = state.AddError(vt.Now())
|
||||
|
||||
// Should fail
|
||||
result = state.Check(vt.Now())
|
||||
assert.True(t, option.IsNone(result), "should fail after reaching threshold")
|
||||
})
|
||||
|
||||
t.Run("alternating errors and successes never opens circuit", func(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC))
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateCounter(maxFailures)
|
||||
|
||||
// Alternate errors and successes
|
||||
for i := 0; i < 10; i++ {
|
||||
state = state.AddError(vt.Now())
|
||||
vt.Advance(500 * time.Millisecond)
|
||||
state = state.AddSuccess(vt.Now())
|
||||
vt.Advance(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Should still pass (never had consecutive failures)
|
||||
result := state.Check(vt.Now())
|
||||
assert.True(t, option.IsSome(result), "alternating errors and successes should never open circuit")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddToSlice(t *testing.T) {
|
||||
ordTime := ord.OrdTime()
|
||||
|
||||
t.Run("adds item to empty slice and returns sorted result", func(t *testing.T) {
|
||||
input := []time.Time{}
|
||||
item := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
result := addToSlice(ordTime, input, item)
|
||||
|
||||
assert.Len(t, result, 1, "result should have 1 element")
|
||||
assert.Equal(t, item, result[0], "result should contain the added item")
|
||||
})
|
||||
|
||||
t.Run("adds item and maintains sorted order", func(t *testing.T) {
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
input := []time.Time{
|
||||
baseTime,
|
||||
baseTime.Add(20 * time.Second),
|
||||
baseTime.Add(40 * time.Second),
|
||||
}
|
||||
item := baseTime.Add(30 * time.Second)
|
||||
|
||||
result := addToSlice(ordTime, input, item)
|
||||
|
||||
assert.Len(t, result, 4, "result should have 4 elements")
|
||||
// Verify sorted order
|
||||
assert.Equal(t, baseTime, result[0])
|
||||
assert.Equal(t, baseTime.Add(20*time.Second), result[1])
|
||||
assert.Equal(t, baseTime.Add(30*time.Second), result[2])
|
||||
assert.Equal(t, baseTime.Add(40*time.Second), result[3])
|
||||
})
|
||||
|
||||
t.Run("adds item at beginning when it's earliest", func(t *testing.T) {
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
input := []time.Time{
|
||||
baseTime.Add(20 * time.Second),
|
||||
baseTime.Add(40 * time.Second),
|
||||
}
|
||||
item := baseTime
|
||||
|
||||
result := addToSlice(ordTime, input, item)
|
||||
|
||||
assert.Len(t, result, 3, "result should have 3 elements")
|
||||
assert.Equal(t, baseTime, result[0], "earliest item should be first")
|
||||
assert.Equal(t, baseTime.Add(20*time.Second), result[1])
|
||||
assert.Equal(t, baseTime.Add(40*time.Second), result[2])
|
||||
})
|
||||
|
||||
t.Run("adds item at end when it's latest", func(t *testing.T) {
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
input := []time.Time{
|
||||
baseTime,
|
||||
baseTime.Add(20 * time.Second),
|
||||
}
|
||||
item := baseTime.Add(40 * time.Second)
|
||||
|
||||
result := addToSlice(ordTime, input, item)
|
||||
|
||||
assert.Len(t, result, 3, "result should have 3 elements")
|
||||
assert.Equal(t, baseTime, result[0])
|
||||
assert.Equal(t, baseTime.Add(20*time.Second), result[1])
|
||||
assert.Equal(t, baseTime.Add(40*time.Second), result[2], "latest item should be last")
|
||||
})
|
||||
|
||||
t.Run("does not modify original slice", func(t *testing.T) {
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
input := []time.Time{
|
||||
baseTime,
|
||||
baseTime.Add(20 * time.Second),
|
||||
}
|
||||
originalLen := len(input)
|
||||
originalFirst := input[0]
|
||||
originalLast := input[1]
|
||||
item := baseTime.Add(10 * time.Second)
|
||||
|
||||
result := addToSlice(ordTime, input, item)
|
||||
|
||||
// Verify original slice is unchanged
|
||||
assert.Len(t, input, originalLen, "original slice length should be unchanged")
|
||||
assert.Equal(t, originalFirst, input[0], "original slice first element should be unchanged")
|
||||
assert.Equal(t, originalLast, input[1], "original slice last element should be unchanged")
|
||||
|
||||
// Verify result is different and has correct length
|
||||
assert.Len(t, result, 3, "result should have new length")
|
||||
// Verify the result contains the new item in sorted order
|
||||
assert.Equal(t, baseTime, result[0])
|
||||
assert.Equal(t, baseTime.Add(10*time.Second), result[1])
|
||||
assert.Equal(t, baseTime.Add(20*time.Second), result[2])
|
||||
})
|
||||
|
||||
t.Run("handles duplicate timestamps", func(t *testing.T) {
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
input := []time.Time{
|
||||
baseTime,
|
||||
baseTime.Add(20 * time.Second),
|
||||
}
|
||||
item := baseTime // duplicate of first element
|
||||
|
||||
result := addToSlice(ordTime, input, item)
|
||||
|
||||
assert.Len(t, result, 3, "result should have 3 elements including duplicate")
|
||||
// Both instances of baseTime should be present
|
||||
count := 0
|
||||
for _, t := range result {
|
||||
if t.Equal(baseTime) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 2, count, "should have 2 instances of the duplicate timestamp")
|
||||
})
|
||||
|
||||
t.Run("maintains sort order with unsorted input", func(t *testing.T) {
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
// Input is intentionally unsorted
|
||||
input := []time.Time{
|
||||
baseTime.Add(40 * time.Second),
|
||||
baseTime,
|
||||
baseTime.Add(20 * time.Second),
|
||||
}
|
||||
item := baseTime.Add(30 * time.Second)
|
||||
|
||||
result := addToSlice(ordTime, input, item)
|
||||
|
||||
assert.Len(t, result, 4, "result should have 4 elements")
|
||||
// Verify result is sorted regardless of input order
|
||||
for i := 0; i < len(result)-1; i++ {
|
||||
assert.True(t, result[i].Before(result[i+1]) || result[i].Equal(result[i+1]),
|
||||
"result should be sorted: element %d (%v) should be <= element %d (%v)",
|
||||
i, result[i], i+1, result[i+1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("works with nanosecond precision", func(t *testing.T) {
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
input := []time.Time{
|
||||
baseTime,
|
||||
baseTime.Add(2 * time.Nanosecond),
|
||||
}
|
||||
item := baseTime.Add(1 * time.Nanosecond)
|
||||
|
||||
result := addToSlice(ordTime, input, item)
|
||||
|
||||
assert.Len(t, result, 3, "result should have 3 elements")
|
||||
assert.Equal(t, baseTime, result[0])
|
||||
assert.Equal(t, baseTime.Add(1*time.Nanosecond), result[1])
|
||||
assert.Equal(t, baseTime.Add(2*time.Nanosecond), result[2])
|
||||
})
|
||||
}
|
||||
|
||||
func TestMakeClosedStateHistory(t *testing.T) {
|
||||
t.Run("creates a valid ClosedState", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
|
||||
assert.NotNil(t, state, "MakeClosedStateHistory should return a non-nil ClosedState")
|
||||
})
|
||||
|
||||
t.Run("initial state passes Check", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
result := state.Check(now)
|
||||
|
||||
assert.True(t, option.IsSome(result), "initial state should pass Check (return Some, circuit stays closed)")
|
||||
})
|
||||
|
||||
t.Run("Empty purges failure history", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(2)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add some errors
|
||||
state = state.AddError(baseTime)
|
||||
state = state.AddError(baseTime.Add(10 * time.Second))
|
||||
|
||||
// Reset the state
|
||||
state = state.Empty()
|
||||
|
||||
// Should pass check after reset
|
||||
result := state.Check(baseTime.Add(20 * time.Second))
|
||||
assert.True(t, option.IsSome(result), "state should pass Check after Empty")
|
||||
})
|
||||
|
||||
t.Run("AddSuccess purges entire failure history", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add errors
|
||||
state = state.AddError(baseTime)
|
||||
state = state.AddError(baseTime.Add(10 * time.Second))
|
||||
|
||||
// Add success (should purge all history)
|
||||
state = state.AddSuccess(baseTime.Add(20 * time.Second))
|
||||
|
||||
// Add another error (this is now the first error in history)
|
||||
state = state.AddError(baseTime.Add(30 * time.Second))
|
||||
|
||||
// Should still pass check (only 1 error in history, threshold is 3)
|
||||
result := state.Check(baseTime.Add(30 * time.Second))
|
||||
assert.True(t, option.IsSome(result), "AddSuccess should purge entire failure history")
|
||||
})
|
||||
|
||||
t.Run("circuit opens when failures reach threshold within time window", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add errors within time window but not reaching threshold
|
||||
state = state.AddError(baseTime)
|
||||
state = state.AddError(baseTime.Add(10 * time.Second))
|
||||
|
||||
// Should still pass before threshold
|
||||
result := state.Check(baseTime.Add(20 * time.Second))
|
||||
assert.True(t, option.IsSome(result), "should pass Check before threshold")
|
||||
|
||||
// Add one more error to reach threshold
|
||||
state = state.AddError(baseTime.Add(30 * time.Second))
|
||||
|
||||
// Should fail check at threshold
|
||||
result = state.Check(baseTime.Add(30 * time.Second))
|
||||
assert.True(t, option.IsNone(result), "should fail Check when reaching threshold")
|
||||
})
|
||||
|
||||
t.Run("old failures outside time window are automatically removed", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add errors that will be outside the time window
|
||||
state = state.AddError(baseTime)
|
||||
state = state.AddError(baseTime.Add(10 * time.Second))
|
||||
|
||||
// Add error after time window has passed (this should remove old errors)
|
||||
state = state.AddError(baseTime.Add(2 * time.Minute))
|
||||
|
||||
// Should pass check (only 1 error in window, old ones removed)
|
||||
result := state.Check(baseTime.Add(2 * time.Minute))
|
||||
assert.True(t, option.IsSome(result), "old failures should be removed from history")
|
||||
})
|
||||
|
||||
t.Run("failures within time window are retained", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add errors within time window
|
||||
state = state.AddError(baseTime)
|
||||
state = state.AddError(baseTime.Add(30 * time.Second))
|
||||
state = state.AddError(baseTime.Add(50 * time.Second))
|
||||
|
||||
// All errors are within 1 minute window, should fail check
|
||||
result := state.Check(baseTime.Add(50 * time.Second))
|
||||
assert.True(t, option.IsNone(result), "failures within time window should be retained")
|
||||
})
|
||||
|
||||
t.Run("sliding window behavior - errors slide out over time", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add 3 errors to reach threshold
|
||||
state = state.AddError(baseTime)
|
||||
state = state.AddError(baseTime.Add(10 * time.Second))
|
||||
state = state.AddError(baseTime.Add(20 * time.Second))
|
||||
|
||||
// Circuit should be open
|
||||
result := state.Check(baseTime.Add(20 * time.Second))
|
||||
assert.True(t, option.IsNone(result), "circuit should be open with 3 failures")
|
||||
|
||||
// Add error after first failure has expired (> 1 minute from first error)
|
||||
// This should remove the first error, leaving only 3 in window
|
||||
state = state.AddError(baseTime.Add(70 * time.Second))
|
||||
|
||||
// Should still fail check (3 errors in window after pruning)
|
||||
result = state.Check(baseTime.Add(70 * time.Second))
|
||||
assert.True(t, option.IsNone(result), "circuit should remain open with 3 failures in window")
|
||||
})
|
||||
|
||||
t.Run("zero maxFailures means circuit is always open", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(0)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Initial state should already fail (0 >= 0)
|
||||
result := state.Check(baseTime)
|
||||
assert.True(t, option.IsNone(result), "initial state should fail Check with maxFailures=0")
|
||||
|
||||
// Add one error
|
||||
state = state.AddError(baseTime)
|
||||
|
||||
// Should still fail
|
||||
result = state.Check(baseTime)
|
||||
assert.True(t, option.IsNone(result), "should fail Check after error with maxFailures=0")
|
||||
})
|
||||
|
||||
t.Run("success purges history even with failures in time window", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add errors within time window
|
||||
state = state.AddError(baseTime)
|
||||
state = state.AddError(baseTime.Add(10 * time.Second))
|
||||
|
||||
// Add success (purges all history)
|
||||
state = state.AddSuccess(baseTime.Add(20 * time.Second))
|
||||
|
||||
// Add more errors
|
||||
state = state.AddError(baseTime.Add(30 * time.Second))
|
||||
state = state.AddError(baseTime.Add(40 * time.Second))
|
||||
|
||||
// Should still pass (only 2 errors after purge)
|
||||
result := state.Check(baseTime.Add(40 * time.Second))
|
||||
assert.True(t, option.IsSome(result), "success should purge all history")
|
||||
|
||||
// Add one more to reach threshold
|
||||
state = state.AddError(baseTime.Add(50 * time.Second))
|
||||
|
||||
// Should fail at threshold
|
||||
result = state.Check(baseTime.Add(50 * time.Second))
|
||||
assert.True(t, option.IsNone(result), "should fail after reaching threshold")
|
||||
})
|
||||
|
||||
t.Run("multiple successes keep history empty", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(2)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add error
|
||||
state = state.AddError(baseTime)
|
||||
|
||||
// Multiple successes
|
||||
state = state.AddSuccess(baseTime.Add(10 * time.Second))
|
||||
state = state.AddSuccess(baseTime.Add(20 * time.Second))
|
||||
state = state.AddSuccess(baseTime.Add(30 * time.Second))
|
||||
|
||||
// Should still pass
|
||||
result := state.Check(baseTime.Add(30 * time.Second))
|
||||
assert.True(t, option.IsSome(result), "multiple AddSuccess should keep history empty")
|
||||
|
||||
// Add errors to reach threshold
|
||||
state = state.AddError(baseTime.Add(40 * time.Second))
|
||||
state = state.AddError(baseTime.Add(50 * time.Second))
|
||||
|
||||
// Should fail
|
||||
result = state.Check(baseTime.Add(50 * time.Second))
|
||||
assert.True(t, option.IsNone(result), "should fail after reaching threshold")
|
||||
})
|
||||
|
||||
t.Run("state is immutable - original unchanged after AddError", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(2)
|
||||
originalState := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Create new state by adding error
|
||||
newState := originalState.AddError(baseTime)
|
||||
|
||||
// Original should still pass check
|
||||
result := originalState.Check(baseTime)
|
||||
assert.True(t, option.IsSome(result), "original state should be unchanged")
|
||||
|
||||
// New state should reach threshold after another error
|
||||
newState = newState.AddError(baseTime.Add(10 * time.Second))
|
||||
|
||||
result = newState.Check(baseTime.Add(10 * time.Second))
|
||||
assert.True(t, option.IsNone(result), "new state should fail after reaching threshold")
|
||||
|
||||
// Original should still pass
|
||||
result = originalState.Check(baseTime)
|
||||
assert.True(t, option.IsSome(result), "original state should still be unchanged")
|
||||
})
|
||||
|
||||
t.Run("state is immutable - original unchanged after Empty", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(2)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add errors to original
|
||||
state = state.AddError(baseTime)
|
||||
state = state.AddError(baseTime.Add(10 * time.Second))
|
||||
stateWithErrors := state
|
||||
|
||||
// Create new state by calling Empty
|
||||
emptyState := stateWithErrors.Empty()
|
||||
|
||||
// Original with errors should fail check
|
||||
result := stateWithErrors.Check(baseTime.Add(10 * time.Second))
|
||||
assert.True(t, option.IsNone(result), "state with errors should fail after reaching threshold")
|
||||
|
||||
// Empty state should pass
|
||||
result = emptyState.Check(baseTime.Add(10 * time.Second))
|
||||
assert.True(t, option.IsSome(result), "empty state should pass Check")
|
||||
})
|
||||
|
||||
t.Run("exact time window boundary behavior", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add error at baseTime
|
||||
state = state.AddError(baseTime)
|
||||
|
||||
// Add error exactly at time window boundary
|
||||
state = state.AddError(baseTime.Add(1 * time.Minute))
|
||||
|
||||
// The first error should be removed (it's now outside the window)
|
||||
// Only 1 error should remain
|
||||
result := state.Check(baseTime.Add(1 * time.Minute))
|
||||
assert.True(t, option.IsSome(result), "error at exact window boundary should remove older errors")
|
||||
})
|
||||
|
||||
t.Run("multiple errors at same timestamp", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add multiple errors at same time
|
||||
state = state.AddError(baseTime)
|
||||
state = state.AddError(baseTime)
|
||||
state = state.AddError(baseTime)
|
||||
|
||||
// Should fail check (3 errors at same time)
|
||||
result := state.Check(baseTime)
|
||||
assert.True(t, option.IsNone(result), "multiple errors at same timestamp should count separately")
|
||||
})
|
||||
|
||||
t.Run("errors added out of chronological order are sorted", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(4)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add errors out of order
|
||||
state = state.AddError(baseTime.Add(30 * time.Second))
|
||||
state = state.AddError(baseTime.Add(5 * time.Second))
|
||||
state = state.AddError(baseTime.Add(50 * time.Second))
|
||||
|
||||
// Add error that should trigger pruning
|
||||
state = state.AddError(baseTime.Add(70 * time.Second))
|
||||
|
||||
// The error at 5s should be removed (> 1 minute from 70s: 70-5=65 > 60)
|
||||
// Should have 3 errors remaining (30s, 50s, 70s)
|
||||
result := state.Check(baseTime.Add(70 * time.Second))
|
||||
assert.True(t, option.IsSome(result), "errors should be sorted and pruned correctly")
|
||||
})
|
||||
|
||||
t.Run("large time window with many failures", func(t *testing.T) {
|
||||
timeWindow := 24 * time.Hour
|
||||
maxFailures := uint(100)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add many failures within the window
|
||||
for i := 0; i < 99; i++ {
|
||||
state = state.AddError(baseTime.Add(time.Duration(i) * time.Minute))
|
||||
}
|
||||
|
||||
// Should still pass (99 < 100)
|
||||
result := state.Check(baseTime.Add(99 * time.Minute))
|
||||
assert.True(t, option.IsSome(result), "should pass with 99 failures when threshold is 100")
|
||||
|
||||
// Add one more to reach threshold
|
||||
state = state.AddError(baseTime.Add(100 * time.Minute))
|
||||
|
||||
// Should fail
|
||||
result = state.Check(baseTime.Add(100 * time.Minute))
|
||||
assert.True(t, option.IsNone(result), "should fail at threshold with large window")
|
||||
})
|
||||
|
||||
t.Run("very short time window", func(t *testing.T) {
|
||||
timeWindow := 100 * time.Millisecond
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add errors within short window
|
||||
state = state.AddError(baseTime)
|
||||
state = state.AddError(baseTime.Add(50 * time.Millisecond))
|
||||
state = state.AddError(baseTime.Add(90 * time.Millisecond))
|
||||
|
||||
// Should fail (3 errors within 100ms)
|
||||
result := state.Check(baseTime.Add(90 * time.Millisecond))
|
||||
assert.True(t, option.IsNone(result), "should fail with errors in short time window")
|
||||
|
||||
// Add error after window expires
|
||||
state = state.AddError(baseTime.Add(200 * time.Millisecond))
|
||||
|
||||
// Should pass (old errors removed, only 1 in window)
|
||||
result = state.Check(baseTime.Add(200 * time.Millisecond))
|
||||
assert.True(t, option.IsSome(result), "should pass after short window expires")
|
||||
})
|
||||
|
||||
t.Run("success prevents circuit from opening", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(3)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add errors close to threshold
|
||||
state = state.AddError(baseTime)
|
||||
state = state.AddError(baseTime.Add(10 * time.Second))
|
||||
|
||||
// Add success before reaching threshold
|
||||
state = state.AddSuccess(baseTime.Add(20 * time.Second))
|
||||
|
||||
// Add more errors
|
||||
state = state.AddError(baseTime.Add(30 * time.Second))
|
||||
state = state.AddError(baseTime.Add(40 * time.Second))
|
||||
|
||||
// Should still pass (only 2 errors after success purge)
|
||||
result := state.Check(baseTime.Add(40 * time.Second))
|
||||
assert.True(t, option.IsSome(result), "circuit should stay closed after success purge")
|
||||
})
|
||||
|
||||
t.Run("Empty can be called multiple times", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(2)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add errors
|
||||
state = state.AddError(baseTime)
|
||||
state = state.AddError(baseTime.Add(10 * time.Second))
|
||||
state = state.AddError(baseTime.Add(20 * time.Second))
|
||||
|
||||
// Reset multiple times
|
||||
state = state.Empty()
|
||||
state = state.Empty()
|
||||
state = state.Empty()
|
||||
|
||||
// Should still pass
|
||||
result := state.Check(baseTime.Add(30 * time.Second))
|
||||
assert.True(t, option.IsSome(result), "state should pass Check after multiple Empty calls")
|
||||
})
|
||||
|
||||
t.Run("gradual failure accumulation within window", func(t *testing.T) {
|
||||
timeWindow := 1 * time.Minute
|
||||
maxFailures := uint(5)
|
||||
state := MakeClosedStateHistory(timeWindow, maxFailures)
|
||||
baseTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Add failures gradually
|
||||
state = state.AddError(baseTime)
|
||||
state = state.AddError(baseTime.Add(15 * time.Second))
|
||||
state = state.AddError(baseTime.Add(30 * time.Second))
|
||||
state = state.AddError(baseTime.Add(45 * time.Second))
|
||||
|
||||
// Should still pass (4 < 5)
|
||||
result := state.Check(baseTime.Add(45 * time.Second))
|
||||
assert.True(t, option.IsSome(result), "should pass before threshold")
|
||||
|
||||
// Add one more within window
|
||||
state = state.AddError(baseTime.Add(55 * time.Second))
|
||||
|
||||
// Should fail (5 >= 5)
|
||||
result = state.Check(baseTime.Add(55 * time.Second))
|
||||
assert.True(t, option.IsNone(result), "should fail at threshold")
|
||||
})
|
||||
}
|
||||
335
v2/circuitbreaker/error.go
Normal file
335
v2/circuitbreaker/error.go
Normal file
@@ -0,0 +1,335 @@
|
||||
// Package circuitbreaker provides error types and utilities for circuit breaker implementations.
|
||||
package circuitbreaker
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
E "github.com/IBM/fp-go/v2/errors"
|
||||
FH "github.com/IBM/fp-go/v2/http"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
)
|
||||
|
||||
// CircuitBreakerError represents an error that occurs when a circuit breaker is in the open state.
|
||||
//
|
||||
// When a circuit breaker opens due to too many failures, it prevents further operations
|
||||
// from executing until a reset time is reached. This error type communicates that state
|
||||
// and provides information about when the circuit breaker will attempt to close again.
|
||||
//
|
||||
// Fields:
|
||||
// - Name: The name identifying this circuit breaker instance
|
||||
// - ResetAt: The time at which the circuit breaker will transition from open to half-open state
|
||||
//
|
||||
// Thread Safety: This type is immutable and safe for concurrent use.
|
||||
type CircuitBreakerError struct {
|
||||
Name string
|
||||
ResetAt time.Time
|
||||
}
|
||||
|
||||
// Error implements the error interface for CircuitBreakerError.
|
||||
//
|
||||
// Returns a formatted error message indicating that the circuit breaker is open
|
||||
// and when it will attempt to close.
|
||||
//
|
||||
// Returns:
|
||||
// - A string describing the circuit breaker state and reset time
|
||||
//
|
||||
// Thread Safety: This method is safe for concurrent use as it only reads immutable fields.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// err := &CircuitBreakerError{Name: "API", ResetAt: time.Now().Add(30 * time.Second)}
|
||||
// fmt.Println(err.Error())
|
||||
// // Output: circuit breaker is open [API], will close at 2026-01-09 12:20:47.123 +0100 CET
|
||||
func (e *CircuitBreakerError) Error() string {
|
||||
return fmt.Sprintf("circuit breaker is open [%s], will close at %s", e.Name, e.ResetAt)
|
||||
}
|
||||
|
||||
// MakeCircuitBreakerErrorWithName creates a circuit breaker error constructor with a custom name.
|
||||
//
|
||||
// This function returns a constructor that creates CircuitBreakerError instances with a specific
|
||||
// circuit breaker name. This is useful when you have multiple circuit breakers in your system
|
||||
// and want to identify which one is open in error messages.
|
||||
//
|
||||
// Parameters:
|
||||
// - name: The name to identify this circuit breaker in error messages
|
||||
//
|
||||
// Returns:
|
||||
// - A function that takes a reset time and returns a CircuitBreakerError with the specified name
|
||||
//
|
||||
// Thread Safety: The returned function is safe for concurrent use as it creates new error
|
||||
// instances on each call.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// makeDBError := MakeCircuitBreakerErrorWithName("Database Circuit Breaker")
|
||||
// err := makeDBError(time.Now().Add(30 * time.Second))
|
||||
// fmt.Println(err.Error())
|
||||
// // Output: circuit breaker is open [Database Circuit Breaker], will close at 2026-01-09 12:20:47.123 +0100 CET
|
||||
func MakeCircuitBreakerErrorWithName(name string) func(time.Time) error {
|
||||
return func(resetTime time.Time) error {
|
||||
return &CircuitBreakerError{Name: name, ResetAt: resetTime}
|
||||
}
|
||||
}
|
||||
|
||||
// MakeCircuitBreakerError creates a new CircuitBreakerError with the specified reset time.
|
||||
//
|
||||
// This constructor function creates a circuit breaker error that indicates when the
|
||||
// circuit breaker will transition from the open state to the half-open state, allowing
|
||||
// test requests to determine if the underlying service has recovered.
|
||||
//
|
||||
// Parameters:
|
||||
// - resetTime: The time at which the circuit breaker will attempt to close
|
||||
//
|
||||
// Returns:
|
||||
// - An error representing the circuit breaker open state
|
||||
//
|
||||
// Thread Safety: This function is safe for concurrent use as it creates new error
|
||||
// instances on each call.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// resetTime := time.Now().Add(30 * time.Second)
|
||||
// err := MakeCircuitBreakerError(resetTime)
|
||||
// if cbErr, ok := err.(*CircuitBreakerError); ok {
|
||||
// fmt.Printf("Circuit breaker will reset at: %s\n", cbErr.ResetAt)
|
||||
// }
|
||||
var MakeCircuitBreakerError = MakeCircuitBreakerErrorWithName("Generic Circuit Breaker")
|
||||
|
||||
// AnyError converts an error to an Option, wrapping non-nil errors in Some and nil errors in None.
|
||||
//
|
||||
// This variable provides a functional way to handle errors by converting them to Option types.
|
||||
// It's particularly useful in functional programming contexts where you want to treat errors
|
||||
// as optional values rather than using traditional error handling patterns.
|
||||
//
|
||||
// Behavior:
|
||||
// - If the error is non-nil, returns Some(error)
|
||||
// - If the error is nil, returns None
|
||||
//
|
||||
// Thread Safety: This function is pure and safe for concurrent use.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// err := errors.New("something went wrong")
|
||||
// optErr := AnyError(err) // Some(error)
|
||||
//
|
||||
// var noErr error = nil
|
||||
// optNoErr := AnyError(noErr) // None
|
||||
//
|
||||
// // Using in functional pipelines
|
||||
// result := F.Pipe2(
|
||||
// someOperation(),
|
||||
// AnyError,
|
||||
// O.Map(func(e error) string { return e.Error() }),
|
||||
// )
|
||||
var AnyError = option.FromPredicate(E.IsNonNil)
|
||||
|
||||
// shouldOpenCircuit determines if an error should cause a circuit breaker to open.
|
||||
//
|
||||
// This function checks if an error represents an infrastructure or server problem
|
||||
// that indicates the service is unhealthy and should trigger circuit breaker protection.
|
||||
// It examines both the error type and, for HTTP errors, the status code.
|
||||
//
|
||||
// Errors that should open the circuit include:
|
||||
// - HTTP 5xx server errors (500-599) indicating server-side problems
|
||||
// - Network errors (connection refused, connection reset, timeouts)
|
||||
// - DNS resolution errors
|
||||
// - TLS/certificate errors
|
||||
// - Other infrastructure-related errors
|
||||
//
|
||||
// Errors that should NOT open the circuit include:
|
||||
// - HTTP 4xx client errors (bad request, unauthorized, not found, etc.)
|
||||
// - Application-level validation errors
|
||||
// - Business logic errors
|
||||
//
|
||||
// The function unwraps error chains to find the root cause, making it compatible
|
||||
// with wrapped errors created by fmt.Errorf with %w or errors.Join.
|
||||
//
|
||||
// Parameters:
|
||||
// - err: The error to evaluate (may be nil)
|
||||
//
|
||||
// Returns:
|
||||
// - true if the error should cause the circuit to open, false otherwise
|
||||
//
|
||||
// Thread Safety: This function is pure and safe for concurrent use. It does not
|
||||
// modify any state.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // HTTP 500 error - should open circuit
|
||||
// httpErr := &FH.HttpError{...} // status 500
|
||||
// if shouldOpenCircuit(httpErr) {
|
||||
// // Open circuit breaker
|
||||
// }
|
||||
//
|
||||
// // HTTP 404 error - should NOT open circuit (client error)
|
||||
// notFoundErr := &FH.HttpError{...} // status 404
|
||||
// if !shouldOpenCircuit(notFoundErr) {
|
||||
// // Don't open circuit, this is a client error
|
||||
// }
|
||||
//
|
||||
// // Network timeout - should open circuit
|
||||
// timeoutErr := &net.OpError{Op: "dial", Err: syscall.ETIMEDOUT}
|
||||
// if shouldOpenCircuit(timeoutErr) {
|
||||
// // Open circuit breaker
|
||||
// }
|
||||
func shouldOpenCircuit(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for HTTP errors with server status codes (5xx)
|
||||
var httpErr *FH.HttpError
|
||||
if errors.As(err, &httpErr) {
|
||||
statusCode := httpErr.StatusCode()
|
||||
// Only 5xx errors should open the circuit
|
||||
// 4xx errors are client errors and shouldn't affect circuit state
|
||||
return statusCode >= http.StatusInternalServerError && statusCode < 600
|
||||
}
|
||||
|
||||
// Check for network operation errors
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
// Network timeouts should open the circuit
|
||||
if opErr.Timeout() {
|
||||
return true
|
||||
}
|
||||
// Check the underlying error
|
||||
if opErr.Err != nil {
|
||||
return isInfrastructureError(opErr.Err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for DNS errors
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for URL errors (often wrap network errors)
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
if urlErr.Timeout() {
|
||||
return true
|
||||
}
|
||||
// Recursively check the wrapped error
|
||||
return shouldOpenCircuit(urlErr.Err)
|
||||
}
|
||||
|
||||
// Check for specific syscall errors that indicate infrastructure problems
|
||||
return isInfrastructureError(err) || isTLSError(err)
|
||||
}
|
||||
|
||||
// isInfrastructureError checks if an error is a low-level infrastructure error
|
||||
// that should cause the circuit to open.
|
||||
//
|
||||
// This function examines syscall errors to identify network and system-level failures
|
||||
// that indicate the service is unavailable or unreachable.
|
||||
//
|
||||
// Infrastructure errors include:
|
||||
// - ECONNREFUSED: Connection refused (service not listening)
|
||||
// - ECONNRESET: Connection reset by peer (service crashed or network issue)
|
||||
// - ECONNABORTED: Connection aborted (network issue)
|
||||
// - ENETUNREACH: Network unreachable (routing problem)
|
||||
// - EHOSTUNREACH: Host unreachable (host down or network issue)
|
||||
// - EPIPE: Broken pipe (connection closed unexpectedly)
|
||||
// - ETIMEDOUT: Operation timed out (service not responding)
|
||||
//
|
||||
// Parameters:
|
||||
// - err: The error to check
|
||||
//
|
||||
// Returns:
|
||||
// - true if the error is an infrastructure error, false otherwise
|
||||
//
|
||||
// Thread Safety: This function is pure and safe for concurrent use.
|
||||
func isInfrastructureError(err error) bool {
|
||||
|
||||
var syscallErr *syscall.Errno
|
||||
|
||||
if errors.As(err, &syscallErr) {
|
||||
switch *syscallErr {
|
||||
case syscall.ECONNREFUSED,
|
||||
syscall.ECONNRESET,
|
||||
syscall.ECONNABORTED,
|
||||
syscall.ENETUNREACH,
|
||||
syscall.EHOSTUNREACH,
|
||||
syscall.EPIPE,
|
||||
syscall.ETIMEDOUT:
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isTLSError checks if an error is a TLS/certificate error that should cause the circuit to open.
|
||||
//
|
||||
// TLS errors typically indicate infrastructure or configuration problems that prevent
|
||||
// secure communication with the service. These errors suggest the service is not properly
|
||||
// configured or accessible.
|
||||
//
|
||||
// TLS errors include:
|
||||
// - Certificate verification failures (invalid, expired, or malformed certificates)
|
||||
// - Unknown certificate authority errors (untrusted CA)
|
||||
//
|
||||
// Parameters:
|
||||
// - err: The error to check
|
||||
//
|
||||
// Returns:
|
||||
// - true if the error is a TLS/certificate error, false otherwise
|
||||
//
|
||||
// Thread Safety: This function is pure and safe for concurrent use.
|
||||
func isTLSError(err error) bool {
|
||||
// Certificate verification failed
|
||||
var certErr *x509.CertificateInvalidError
|
||||
if errors.As(err, &certErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Unknown authority
|
||||
var unknownAuthErr *x509.UnknownAuthorityError
|
||||
if errors.As(err, &unknownAuthErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// InfrastructureError is a predicate that converts errors to Options based on whether
|
||||
// they should trigger circuit breaker opening.
|
||||
//
|
||||
// This variable provides a functional way to filter errors that represent infrastructure
|
||||
// failures (network issues, server errors, timeouts, etc.) from application-level errors
|
||||
// (validation errors, business logic errors, client errors).
|
||||
//
|
||||
// Behavior:
|
||||
// - Returns Some(error) if the error should open the circuit (infrastructure failure)
|
||||
// - Returns None if the error should not open the circuit (application error)
|
||||
//
|
||||
// Thread Safety: This function is pure and safe for concurrent use.
|
||||
//
|
||||
// Use this in circuit breaker configurations to determine which errors should count
|
||||
// toward the failure threshold.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // In a circuit breaker configuration
|
||||
// breaker := MakeCircuitBreaker(
|
||||
// ...,
|
||||
// checkError: InfrastructureError, // Only infrastructure errors open the circuit
|
||||
// ...,
|
||||
// )
|
||||
//
|
||||
// // HTTP 500 error - returns Some(error)
|
||||
// result := InfrastructureError(&FH.HttpError{...}) // Some(error)
|
||||
//
|
||||
// // HTTP 404 error - returns None
|
||||
// result := InfrastructureError(&FH.HttpError{...}) // None
|
||||
var InfrastructureError = option.FromPredicate(shouldOpenCircuit)
|
||||
503
v2/circuitbreaker/error_test.go
Normal file
503
v2/circuitbreaker/error_test.go
Normal file
@@ -0,0 +1,503 @@
|
||||
package circuitbreaker
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
FH "github.com/IBM/fp-go/v2/http"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestCircuitBreakerError tests the CircuitBreakerError type
|
||||
func TestCircuitBreakerError(t *testing.T) {
|
||||
t.Run("Error returns formatted message with reset time", func(t *testing.T) {
|
||||
resetTime := time.Date(2026, 1, 9, 12, 30, 0, 0, time.UTC)
|
||||
err := &CircuitBreakerError{ResetAt: resetTime}
|
||||
|
||||
result := err.Error()
|
||||
|
||||
assert.Contains(t, result, "circuit breaker is open")
|
||||
assert.Contains(t, result, "will close at")
|
||||
assert.Contains(t, result, resetTime.String())
|
||||
})
|
||||
|
||||
t.Run("Error message includes full timestamp", func(t *testing.T) {
|
||||
resetTime := time.Now().Add(30 * time.Second)
|
||||
err := &CircuitBreakerError{ResetAt: resetTime}
|
||||
|
||||
result := err.Error()
|
||||
|
||||
assert.NotEmpty(t, result)
|
||||
assert.Contains(t, result, "circuit breaker is open")
|
||||
})
|
||||
}
|
||||
|
||||
// TestMakeCircuitBreakerError tests the constructor function
|
||||
func TestMakeCircuitBreakerError(t *testing.T) {
|
||||
t.Run("creates CircuitBreakerError with correct reset time", func(t *testing.T) {
|
||||
resetTime := time.Date(2026, 1, 9, 13, 0, 0, 0, time.UTC)
|
||||
|
||||
err := MakeCircuitBreakerError(resetTime)
|
||||
|
||||
assert.NotNil(t, err)
|
||||
cbErr, ok := err.(*CircuitBreakerError)
|
||||
assert.True(t, ok, "should return *CircuitBreakerError type")
|
||||
assert.Equal(t, resetTime, cbErr.ResetAt)
|
||||
})
|
||||
|
||||
t.Run("returns error interface", func(t *testing.T) {
|
||||
resetTime := time.Now().Add(1 * time.Minute)
|
||||
|
||||
err := MakeCircuitBreakerError(resetTime)
|
||||
|
||||
// Should be assignable to error interface
|
||||
var _ error = err
|
||||
assert.NotNil(t, err)
|
||||
})
|
||||
|
||||
t.Run("created error can be type asserted", func(t *testing.T) {
|
||||
resetTime := time.Now().Add(45 * time.Second)
|
||||
|
||||
err := MakeCircuitBreakerError(resetTime)
|
||||
|
||||
cbErr, ok := err.(*CircuitBreakerError)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, resetTime, cbErr.ResetAt)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAnyError tests the AnyError function
|
||||
func TestAnyError(t *testing.T) {
|
||||
t.Run("returns Some for non-nil error", func(t *testing.T) {
|
||||
err := errors.New("test error")
|
||||
|
||||
result := AnyError(err)
|
||||
|
||||
assert.True(t, option.IsSome(result), "should return Some for non-nil error")
|
||||
value := option.GetOrElse(func() error { return nil })(result)
|
||||
assert.Equal(t, err, value)
|
||||
})
|
||||
|
||||
t.Run("returns None for nil error", func(t *testing.T) {
|
||||
var err error = nil
|
||||
|
||||
result := AnyError(err)
|
||||
|
||||
assert.True(t, option.IsNone(result), "should return None for nil error")
|
||||
})
|
||||
|
||||
t.Run("works with different error types", func(t *testing.T) {
|
||||
err1 := fmt.Errorf("wrapped: %w", errors.New("inner"))
|
||||
err2 := &CircuitBreakerError{ResetAt: time.Now()}
|
||||
|
||||
result1 := AnyError(err1)
|
||||
result2 := AnyError(err2)
|
||||
|
||||
assert.True(t, option.IsSome(result1))
|
||||
assert.True(t, option.IsSome(result2))
|
||||
})
|
||||
}
|
||||
|
||||
// TestShouldOpenCircuit tests the shouldOpenCircuit function
|
||||
func TestShouldOpenCircuit(t *testing.T) {
|
||||
t.Run("returns false for nil error", func(t *testing.T) {
|
||||
result := shouldOpenCircuit(nil)
|
||||
assert.False(t, result)
|
||||
})
|
||||
|
||||
t.Run("HTTP 5xx errors should open circuit", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
expected bool
|
||||
}{
|
||||
{"500 Internal Server Error", 500, true},
|
||||
{"501 Not Implemented", 501, true},
|
||||
{"502 Bad Gateway", 502, true},
|
||||
{"503 Service Unavailable", 503, true},
|
||||
{"504 Gateway Timeout", 504, true},
|
||||
{"599 Custom Server Error", 599, true},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testURL, _ := url.Parse("http://example.com")
|
||||
resp := &http.Response{
|
||||
StatusCode: tc.statusCode,
|
||||
Request: &http.Request{URL: testURL},
|
||||
Body: http.NoBody,
|
||||
}
|
||||
httpErr := FH.StatusCodeError(resp)
|
||||
|
||||
result := shouldOpenCircuit(httpErr)
|
||||
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTTP 4xx errors should NOT open circuit", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
expected bool
|
||||
}{
|
||||
{"400 Bad Request", 400, false},
|
||||
{"401 Unauthorized", 401, false},
|
||||
{"403 Forbidden", 403, false},
|
||||
{"404 Not Found", 404, false},
|
||||
{"422 Unprocessable Entity", 422, false},
|
||||
{"429 Too Many Requests", 429, false},
|
||||
{"499 Custom Client Error", 499, false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testURL, _ := url.Parse("http://example.com")
|
||||
resp := &http.Response{
|
||||
StatusCode: tc.statusCode,
|
||||
Request: &http.Request{URL: testURL},
|
||||
Body: http.NoBody,
|
||||
}
|
||||
httpErr := FH.StatusCodeError(resp)
|
||||
|
||||
result := shouldOpenCircuit(httpErr)
|
||||
|
||||
assert.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTTP 2xx and 3xx should NOT open circuit", func(t *testing.T) {
|
||||
testCases := []int{200, 201, 204, 301, 302, 304}
|
||||
|
||||
for _, statusCode := range testCases {
|
||||
t.Run(fmt.Sprintf("Status %d", statusCode), func(t *testing.T) {
|
||||
testURL, _ := url.Parse("http://example.com")
|
||||
resp := &http.Response{
|
||||
StatusCode: statusCode,
|
||||
Request: &http.Request{URL: testURL},
|
||||
Body: http.NoBody,
|
||||
}
|
||||
httpErr := FH.StatusCodeError(resp)
|
||||
|
||||
result := shouldOpenCircuit(httpErr)
|
||||
|
||||
assert.False(t, result)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("network timeout errors should open circuit", func(t *testing.T) {
|
||||
opErr := &net.OpError{
|
||||
Op: "dial",
|
||||
Err: &timeoutError{},
|
||||
}
|
||||
|
||||
result := shouldOpenCircuit(opErr)
|
||||
|
||||
assert.True(t, result)
|
||||
})
|
||||
|
||||
t.Run("DNS errors should open circuit", func(t *testing.T) {
|
||||
dnsErr := &net.DNSError{
|
||||
Err: "no such host",
|
||||
Name: "example.com",
|
||||
}
|
||||
|
||||
result := shouldOpenCircuit(dnsErr)
|
||||
|
||||
assert.True(t, result)
|
||||
})
|
||||
|
||||
t.Run("URL timeout errors should open circuit", func(t *testing.T) {
|
||||
urlErr := &url.Error{
|
||||
Op: "Get",
|
||||
URL: "http://example.com",
|
||||
Err: &timeoutError{},
|
||||
}
|
||||
|
||||
result := shouldOpenCircuit(urlErr)
|
||||
|
||||
assert.True(t, result)
|
||||
})
|
||||
|
||||
t.Run("URL errors with nested network timeout should open circuit", func(t *testing.T) {
|
||||
urlErr := &url.Error{
|
||||
Op: "Get",
|
||||
URL: "http://example.com",
|
||||
Err: &net.OpError{
|
||||
Op: "dial",
|
||||
Err: &timeoutError{},
|
||||
},
|
||||
}
|
||||
|
||||
result := shouldOpenCircuit(urlErr)
|
||||
|
||||
assert.True(t, result)
|
||||
})
|
||||
|
||||
t.Run("OpError with nil Err should open circuit", func(t *testing.T) {
|
||||
opErr := &net.OpError{
|
||||
Op: "dial",
|
||||
Err: nil,
|
||||
}
|
||||
|
||||
result := shouldOpenCircuit(opErr)
|
||||
|
||||
assert.True(t, result)
|
||||
})
|
||||
|
||||
t.Run("wrapped HTTP 5xx error should open circuit", func(t *testing.T) {
|
||||
testURL, _ := url.Parse("http://example.com")
|
||||
resp := &http.Response{
|
||||
StatusCode: 503,
|
||||
Request: &http.Request{URL: testURL},
|
||||
Body: http.NoBody,
|
||||
}
|
||||
httpErr := FH.StatusCodeError(resp)
|
||||
wrappedErr := fmt.Errorf("service error: %w", httpErr)
|
||||
|
||||
result := shouldOpenCircuit(wrappedErr)
|
||||
|
||||
assert.True(t, result)
|
||||
})
|
||||
|
||||
t.Run("wrapped HTTP 4xx error should NOT open circuit", func(t *testing.T) {
|
||||
testURL, _ := url.Parse("http://example.com")
|
||||
resp := &http.Response{
|
||||
StatusCode: 404,
|
||||
Request: &http.Request{URL: testURL},
|
||||
Body: http.NoBody,
|
||||
}
|
||||
httpErr := FH.StatusCodeError(resp)
|
||||
wrappedErr := fmt.Errorf("not found: %w", httpErr)
|
||||
|
||||
result := shouldOpenCircuit(wrappedErr)
|
||||
|
||||
assert.False(t, result)
|
||||
})
|
||||
|
||||
t.Run("generic application error should NOT open circuit", func(t *testing.T) {
|
||||
err := errors.New("validation failed")
|
||||
|
||||
result := shouldOpenCircuit(err)
|
||||
|
||||
assert.False(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestIsInfrastructureError tests infrastructure error detection through shouldOpenCircuit
|
||||
func TestIsInfrastructureError(t *testing.T) {
|
||||
t.Run("network timeout is infrastructure error", func(t *testing.T) {
|
||||
opErr := &net.OpError{Op: "dial", Err: &timeoutError{}}
|
||||
result := shouldOpenCircuit(opErr)
|
||||
assert.True(t, result)
|
||||
})
|
||||
|
||||
t.Run("OpError with nil Err is infrastructure error", func(t *testing.T) {
|
||||
opErr := &net.OpError{Op: "dial", Err: nil}
|
||||
result := shouldOpenCircuit(opErr)
|
||||
assert.True(t, result)
|
||||
})
|
||||
|
||||
t.Run("generic error returns false", func(t *testing.T) {
|
||||
err := errors.New("generic error")
|
||||
result := shouldOpenCircuit(err)
|
||||
assert.False(t, result)
|
||||
})
|
||||
|
||||
t.Run("wrapped network timeout is detected", func(t *testing.T) {
|
||||
opErr := &net.OpError{Op: "dial", Err: &timeoutError{}}
|
||||
wrappedErr := fmt.Errorf("connection failed: %w", opErr)
|
||||
result := shouldOpenCircuit(wrappedErr)
|
||||
assert.True(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestIsTLSError tests the isTLSError function
|
||||
func TestIsTLSError(t *testing.T) {
|
||||
t.Run("certificate invalid error is TLS error", func(t *testing.T) {
|
||||
certErr := &x509.CertificateInvalidError{
|
||||
Reason: x509.Expired,
|
||||
}
|
||||
|
||||
result := isTLSError(certErr)
|
||||
|
||||
assert.True(t, result)
|
||||
})
|
||||
|
||||
t.Run("unknown authority error is TLS error", func(t *testing.T) {
|
||||
authErr := &x509.UnknownAuthorityError{}
|
||||
|
||||
result := isTLSError(authErr)
|
||||
|
||||
assert.True(t, result)
|
||||
})
|
||||
|
||||
t.Run("generic error is not TLS error", func(t *testing.T) {
|
||||
err := errors.New("generic error")
|
||||
|
||||
result := isTLSError(err)
|
||||
|
||||
assert.False(t, result)
|
||||
})
|
||||
|
||||
t.Run("wrapped certificate error is detected", func(t *testing.T) {
|
||||
certErr := &x509.CertificateInvalidError{
|
||||
Reason: x509.Expired,
|
||||
}
|
||||
wrappedErr := fmt.Errorf("TLS handshake failed: %w", certErr)
|
||||
|
||||
result := isTLSError(wrappedErr)
|
||||
|
||||
assert.True(t, result)
|
||||
})
|
||||
|
||||
t.Run("wrapped unknown authority error is detected", func(t *testing.T) {
|
||||
authErr := &x509.UnknownAuthorityError{}
|
||||
wrappedErr := fmt.Errorf("certificate verification failed: %w", authErr)
|
||||
|
||||
result := isTLSError(wrappedErr)
|
||||
|
||||
assert.True(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestInfrastructureError tests the InfrastructureError variable
|
||||
func TestInfrastructureError(t *testing.T) {
|
||||
t.Run("returns Some for infrastructure errors", func(t *testing.T) {
|
||||
testURL, _ := url.Parse("http://example.com")
|
||||
resp := &http.Response{
|
||||
StatusCode: 503,
|
||||
Request: &http.Request{URL: testURL},
|
||||
Body: http.NoBody,
|
||||
}
|
||||
httpErr := FH.StatusCodeError(resp)
|
||||
|
||||
result := InfrastructureError(httpErr)
|
||||
|
||||
assert.True(t, option.IsSome(result))
|
||||
})
|
||||
|
||||
t.Run("returns None for non-infrastructure errors", func(t *testing.T) {
|
||||
testURL, _ := url.Parse("http://example.com")
|
||||
resp := &http.Response{
|
||||
StatusCode: 404,
|
||||
Request: &http.Request{URL: testURL},
|
||||
Body: http.NoBody,
|
||||
}
|
||||
httpErr := FH.StatusCodeError(resp)
|
||||
|
||||
result := InfrastructureError(httpErr)
|
||||
|
||||
assert.True(t, option.IsNone(result))
|
||||
})
|
||||
|
||||
t.Run("returns None for nil error", func(t *testing.T) {
|
||||
result := InfrastructureError(nil)
|
||||
|
||||
assert.True(t, option.IsNone(result))
|
||||
})
|
||||
|
||||
t.Run("returns Some for network timeout", func(t *testing.T) {
|
||||
opErr := &net.OpError{
|
||||
Op: "dial",
|
||||
Err: &timeoutError{},
|
||||
}
|
||||
|
||||
result := InfrastructureError(opErr)
|
||||
|
||||
assert.True(t, option.IsSome(result))
|
||||
})
|
||||
}
|
||||
|
||||
// TestComplexErrorScenarios tests complex real-world error scenarios
|
||||
func TestComplexErrorScenarios(t *testing.T) {
|
||||
t.Run("deeply nested URL error with HTTP 5xx", func(t *testing.T) {
|
||||
testURL, _ := url.Parse("http://api.example.com")
|
||||
resp := &http.Response{
|
||||
StatusCode: 502,
|
||||
Request: &http.Request{URL: testURL},
|
||||
Body: http.NoBody,
|
||||
}
|
||||
httpErr := FH.StatusCodeError(resp)
|
||||
urlErr := &url.Error{
|
||||
Op: "Get",
|
||||
URL: "http://api.example.com",
|
||||
Err: httpErr,
|
||||
}
|
||||
wrappedErr := fmt.Errorf("API call failed: %w", urlErr)
|
||||
|
||||
result := shouldOpenCircuit(wrappedErr)
|
||||
|
||||
assert.True(t, result, "should detect HTTP 5xx through multiple layers")
|
||||
})
|
||||
|
||||
t.Run("URL error with timeout nested in OpError", func(t *testing.T) {
|
||||
opErr := &net.OpError{
|
||||
Op: "dial",
|
||||
Err: &timeoutError{},
|
||||
}
|
||||
urlErr := &url.Error{
|
||||
Op: "Post",
|
||||
URL: "http://api.example.com",
|
||||
Err: opErr,
|
||||
}
|
||||
|
||||
result := shouldOpenCircuit(urlErr)
|
||||
|
||||
assert.True(t, result, "should detect timeout through URL error")
|
||||
})
|
||||
|
||||
t.Run("multiple wrapped errors with infrastructure error at core", func(t *testing.T) {
|
||||
coreErr := &net.OpError{Op: "dial", Err: &timeoutError{}}
|
||||
layer1 := fmt.Errorf("connection attempt failed: %w", coreErr)
|
||||
layer2 := fmt.Errorf("retry exhausted: %w", layer1)
|
||||
layer3 := fmt.Errorf("service unavailable: %w", layer2)
|
||||
|
||||
result := shouldOpenCircuit(layer3)
|
||||
|
||||
assert.True(t, result, "should unwrap to find infrastructure error")
|
||||
})
|
||||
|
||||
t.Run("OpError with nil Err should open circuit", func(t *testing.T) {
|
||||
opErr := &net.OpError{
|
||||
Op: "dial",
|
||||
Err: nil,
|
||||
}
|
||||
|
||||
result := shouldOpenCircuit(opErr)
|
||||
|
||||
assert.True(t, result, "OpError with nil Err should be treated as infrastructure error")
|
||||
})
|
||||
|
||||
t.Run("mixed error types - HTTP 4xx with network error", func(t *testing.T) {
|
||||
// This tests that we correctly identify the error type
|
||||
testURL, _ := url.Parse("http://example.com")
|
||||
resp := &http.Response{
|
||||
StatusCode: 400,
|
||||
Request: &http.Request{URL: testURL},
|
||||
Body: http.NoBody,
|
||||
}
|
||||
httpErr := FH.StatusCodeError(resp)
|
||||
|
||||
result := shouldOpenCircuit(httpErr)
|
||||
|
||||
assert.False(t, result, "HTTP 4xx should not open circuit even if wrapped")
|
||||
})
|
||||
}
|
||||
|
||||
// Helper type for testing timeout errors
|
||||
type timeoutError struct{}
|
||||
|
||||
func (e *timeoutError) Error() string { return "timeout" }
|
||||
func (e *timeoutError) Timeout() bool { return true }
|
||||
func (e *timeoutError) Temporary() bool { return true }
|
||||
208
v2/circuitbreaker/metrics.go
Normal file
208
v2/circuitbreaker/metrics.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// Package circuitbreaker provides metrics collection for circuit breaker state transitions and events.
|
||||
package circuitbreaker
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/IBM/fp-go/v2/function"
|
||||
)
|
||||
|
||||
type (
|
||||
// Metrics defines the interface for collecting circuit breaker metrics and events.
|
||||
// Implementations can use this interface to track circuit breaker behavior for
|
||||
// monitoring, alerting, and debugging purposes.
|
||||
//
|
||||
// All methods accept a time.Time parameter representing when the event occurred,
|
||||
// and return an IO[Void] operation that performs the metric recording when executed.
|
||||
//
|
||||
// Thread Safety: Implementations must be thread-safe as circuit breakers may be
|
||||
// accessed concurrently from multiple goroutines.
|
||||
//
|
||||
// Example Usage:
|
||||
//
|
||||
// logger := log.New(os.Stdout, "[CircuitBreaker] ", log.LstdFlags)
|
||||
// metrics := MakeMetricsFromLogger("API-Service", logger)
|
||||
//
|
||||
// // In circuit breaker implementation
|
||||
// io.Run(metrics.Accept(time.Now())) // Record accepted request
|
||||
// io.Run(metrics.Reject(time.Now())) // Record rejected request
|
||||
// io.Run(metrics.Open(time.Now())) // Record circuit opening
|
||||
// io.Run(metrics.Close(time.Now())) // Record circuit closing
|
||||
// io.Run(metrics.Canary(time.Now())) // Record canary request
|
||||
Metrics interface {
|
||||
// Accept records that a request was accepted and allowed through the circuit breaker.
|
||||
// This is called when the circuit is closed or in half-open state (canary request).
|
||||
//
|
||||
// Parameters:
|
||||
// - time.Time: The timestamp when the request was accepted
|
||||
//
|
||||
// Returns:
|
||||
// - IO[Void]: An IO operation that records the acceptance when executed
|
||||
//
|
||||
// Thread Safety: Must be safe to call concurrently.
|
||||
Accept(time.Time) IO[Void]
|
||||
|
||||
// Reject records that a request was rejected because the circuit breaker is open.
|
||||
// This is called when a request is blocked due to the circuit being in open state
|
||||
// and the reset time has not been reached.
|
||||
//
|
||||
// Parameters:
|
||||
// - time.Time: The timestamp when the request was rejected
|
||||
//
|
||||
// Returns:
|
||||
// - IO[Void]: An IO operation that records the rejection when executed
|
||||
//
|
||||
// Thread Safety: Must be safe to call concurrently.
|
||||
Reject(time.Time) IO[Void]
|
||||
|
||||
// Open records that the circuit breaker transitioned to the open state.
|
||||
// This is called when the failure threshold is exceeded and the circuit opens
|
||||
// to prevent further requests from reaching the failing service.
|
||||
//
|
||||
// Parameters:
|
||||
// - time.Time: The timestamp when the circuit opened
|
||||
//
|
||||
// Returns:
|
||||
// - IO[Void]: An IO operation that records the state transition when executed
|
||||
//
|
||||
// Thread Safety: Must be safe to call concurrently.
|
||||
Open(time.Time) IO[Void]
|
||||
|
||||
// Close records that the circuit breaker transitioned to the closed state.
|
||||
// This is called when:
|
||||
// - A canary request succeeds in half-open state
|
||||
// - The circuit is manually reset
|
||||
// - The circuit breaker is initialized
|
||||
//
|
||||
// Parameters:
|
||||
// - time.Time: The timestamp when the circuit closed
|
||||
//
|
||||
// Returns:
|
||||
// - IO[Void]: An IO operation that records the state transition when executed
|
||||
//
|
||||
// Thread Safety: Must be safe to call concurrently.
|
||||
Close(time.Time) IO[Void]
|
||||
|
||||
// Canary records that a canary (test) request is being attempted.
|
||||
// This is called when the circuit is in half-open state and a single test request
|
||||
// is allowed through to check if the service has recovered.
|
||||
//
|
||||
// Parameters:
|
||||
// - time.Time: The timestamp when the canary request was initiated
|
||||
//
|
||||
// Returns:
|
||||
// - IO[Void]: An IO operation that records the canary attempt when executed
|
||||
//
|
||||
// Thread Safety: Must be safe to call concurrently.
|
||||
Canary(time.Time) IO[Void]
|
||||
}
|
||||
|
||||
// loggingMetrics is a simple implementation of the Metrics interface that logs
|
||||
// circuit breaker events using Go's standard log.Logger.
|
||||
//
|
||||
// This implementation is thread-safe as log.Logger is safe for concurrent use.
|
||||
//
|
||||
// Fields:
|
||||
// - name: A human-readable name identifying the circuit breaker instance
|
||||
// - logger: The log.Logger instance used for writing log messages
|
||||
loggingMetrics struct {
|
||||
name string
|
||||
logger *log.Logger
|
||||
}
|
||||
)
|
||||
|
||||
// doLog is a helper method that creates an IO operation for logging a circuit breaker event.
|
||||
// It formats the log message with the event prefix, circuit breaker name, and timestamp.
|
||||
//
|
||||
// Parameters:
|
||||
// - prefix: The event type (e.g., "Accept", "Reject", "Open", "Close", "Canary")
|
||||
// - ct: The timestamp when the event occurred
|
||||
//
|
||||
// Returns:
|
||||
// - IO[Void]: An IO operation that logs the event when executed
|
||||
//
|
||||
// Thread Safety: Safe for concurrent use as log.Logger is thread-safe.
|
||||
//
|
||||
// Log Format: "<prefix>: <name>, <timestamp>"
|
||||
// Example: "Open: API-Service, 2026-01-09 15:30:45.123 +0100 CET"
|
||||
func (m *loggingMetrics) doLog(prefix string, ct time.Time) IO[Void] {
|
||||
return func() Void {
|
||||
m.logger.Printf("%s: %s, %s\n", prefix, m.name, ct)
|
||||
return function.VOID
|
||||
}
|
||||
}
|
||||
|
||||
// Accept implements the Metrics interface for loggingMetrics.
|
||||
// Logs when a request is accepted through the circuit breaker.
|
||||
//
|
||||
// Thread Safety: Safe for concurrent use.
|
||||
func (m *loggingMetrics) Accept(ct time.Time) IO[Void] {
|
||||
return m.doLog("Accept", ct)
|
||||
}
|
||||
|
||||
// Open implements the Metrics interface for loggingMetrics.
|
||||
// Logs when the circuit breaker transitions to open state.
|
||||
//
|
||||
// Thread Safety: Safe for concurrent use.
|
||||
func (m *loggingMetrics) Open(ct time.Time) IO[Void] {
|
||||
return m.doLog("Open", ct)
|
||||
}
|
||||
|
||||
// Close implements the Metrics interface for loggingMetrics.
|
||||
// Logs when the circuit breaker transitions to closed state.
|
||||
//
|
||||
// Thread Safety: Safe for concurrent use.
|
||||
func (m *loggingMetrics) Close(ct time.Time) IO[Void] {
|
||||
return m.doLog("Close", ct)
|
||||
}
|
||||
|
||||
// Reject implements the Metrics interface for loggingMetrics.
|
||||
// Logs when a request is rejected because the circuit breaker is open.
|
||||
//
|
||||
// Thread Safety: Safe for concurrent use.
|
||||
func (m *loggingMetrics) Reject(ct time.Time) IO[Void] {
|
||||
return m.doLog("Reject", ct)
|
||||
}
|
||||
|
||||
// Canary implements the Metrics interface for loggingMetrics.
|
||||
// Logs when a canary (test) request is attempted in half-open state.
|
||||
//
|
||||
// Thread Safety: Safe for concurrent use.
|
||||
func (m *loggingMetrics) Canary(ct time.Time) IO[Void] {
|
||||
return m.doLog("Canary", ct)
|
||||
}
|
||||
|
||||
// MakeMetricsFromLogger creates a Metrics implementation that logs circuit breaker events
|
||||
// using the provided log.Logger.
|
||||
//
|
||||
// This is a simple metrics implementation suitable for development, debugging, and
|
||||
// basic production monitoring. For more sophisticated metrics collection (e.g., Prometheus,
|
||||
// StatsD), implement the Metrics interface with a custom type.
|
||||
//
|
||||
// Parameters:
|
||||
// - name: A human-readable name identifying the circuit breaker instance.
|
||||
// This name appears in all log messages to distinguish between multiple circuit breakers.
|
||||
// - logger: The log.Logger instance to use for writing log messages.
|
||||
// If nil, this will panic when metrics are recorded.
|
||||
//
|
||||
// Returns:
|
||||
// - Metrics: A thread-safe Metrics implementation that logs events
|
||||
//
|
||||
// Thread Safety: The returned Metrics implementation is safe for concurrent use
|
||||
// as log.Logger is thread-safe.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// logger := log.New(os.Stdout, "[CB] ", log.LstdFlags)
|
||||
// metrics := MakeMetricsFromLogger("UserService", logger)
|
||||
//
|
||||
// // Use with circuit breaker
|
||||
// io.Run(metrics.Open(time.Now()))
|
||||
// // Output: [CB] 2026/01/09 15:30:45 Open: UserService, 2026-01-09 15:30:45.123 +0100 CET
|
||||
//
|
||||
// io.Run(metrics.Reject(time.Now()))
|
||||
// // Output: [CB] 2026/01/09 15:30:46 Reject: UserService, 2026-01-09 15:30:46.456 +0100 CET
|
||||
func MakeMetricsFromLogger(name string, logger *log.Logger) Metrics {
|
||||
return &loggingMetrics{name: name, logger: logger}
|
||||
}
|
||||
506
v2/circuitbreaker/metrics_test.go
Normal file
506
v2/circuitbreaker/metrics_test.go
Normal file
@@ -0,0 +1,506 @@
|
||||
package circuitbreaker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestMakeMetricsFromLogger tests the MakeMetricsFromLogger constructor
|
||||
func TestMakeMetricsFromLogger(t *testing.T) {
|
||||
t.Run("creates valid Metrics implementation", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
|
||||
assert.NotNil(t, metrics, "MakeMetricsFromLogger should return non-nil Metrics")
|
||||
})
|
||||
|
||||
t.Run("returns loggingMetrics type", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
|
||||
_, ok := metrics.(*loggingMetrics)
|
||||
assert.True(t, ok, "should return *loggingMetrics type")
|
||||
})
|
||||
|
||||
t.Run("stores name correctly", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
name := "MyCircuitBreaker"
|
||||
|
||||
metrics := MakeMetricsFromLogger(name, logger).(*loggingMetrics)
|
||||
|
||||
assert.Equal(t, name, metrics.name, "name should be stored correctly")
|
||||
})
|
||||
|
||||
t.Run("stores logger correctly", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger).(*loggingMetrics)
|
||||
|
||||
assert.Equal(t, logger, metrics.logger, "logger should be stored correctly")
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoggingMetricsAccept tests the Accept method
|
||||
func TestLoggingMetricsAccept(t *testing.T) {
|
||||
t.Run("logs accept event with correct format", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
|
||||
|
||||
io.Run(metrics.Accept(timestamp))
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "Accept:", "should contain Accept prefix")
|
||||
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
|
||||
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
|
||||
})
|
||||
|
||||
t.Run("returns IO[Void] that can be executed", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
timestamp := time.Now()
|
||||
|
||||
ioOp := metrics.Accept(timestamp)
|
||||
|
||||
assert.NotNil(t, ioOp, "should return non-nil IO operation")
|
||||
result := io.Run(ioOp)
|
||||
assert.NotNil(t, result, "IO operation should execute successfully")
|
||||
})
|
||||
|
||||
t.Run("logs multiple accept events", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
time1 := time.Date(2026, 1, 9, 15, 30, 0, 0, time.UTC)
|
||||
time2 := time.Date(2026, 1, 9, 15, 31, 0, 0, time.UTC)
|
||||
|
||||
io.Run(metrics.Accept(time1))
|
||||
io.Run(metrics.Accept(time2))
|
||||
|
||||
output := buf.String()
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
assert.Len(t, lines, 2, "should have 2 log lines")
|
||||
assert.Contains(t, lines[0], time1.String())
|
||||
assert.Contains(t, lines[1], time2.String())
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoggingMetricsReject tests the Reject method
|
||||
func TestLoggingMetricsReject(t *testing.T) {
|
||||
t.Run("logs reject event with correct format", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
|
||||
|
||||
io.Run(metrics.Reject(timestamp))
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "Reject:", "should contain Reject prefix")
|
||||
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
|
||||
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
|
||||
})
|
||||
|
||||
t.Run("returns IO[Void] that can be executed", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
timestamp := time.Now()
|
||||
|
||||
ioOp := metrics.Reject(timestamp)
|
||||
|
||||
assert.NotNil(t, ioOp, "should return non-nil IO operation")
|
||||
result := io.Run(ioOp)
|
||||
assert.NotNil(t, result, "IO operation should execute successfully")
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoggingMetricsOpen tests the Open method
|
||||
func TestLoggingMetricsOpen(t *testing.T) {
|
||||
t.Run("logs open event with correct format", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
|
||||
|
||||
io.Run(metrics.Open(timestamp))
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "Open:", "should contain Open prefix")
|
||||
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
|
||||
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
|
||||
})
|
||||
|
||||
t.Run("returns IO[Void] that can be executed", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
timestamp := time.Now()
|
||||
|
||||
ioOp := metrics.Open(timestamp)
|
||||
|
||||
assert.NotNil(t, ioOp, "should return non-nil IO operation")
|
||||
result := io.Run(ioOp)
|
||||
assert.NotNil(t, result, "IO operation should execute successfully")
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoggingMetricsClose tests the Close method
|
||||
func TestLoggingMetricsClose(t *testing.T) {
|
||||
t.Run("logs close event with correct format", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
|
||||
|
||||
io.Run(metrics.Close(timestamp))
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "Close:", "should contain Close prefix")
|
||||
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
|
||||
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
|
||||
})
|
||||
|
||||
t.Run("returns IO[Void] that can be executed", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
timestamp := time.Now()
|
||||
|
||||
ioOp := metrics.Close(timestamp)
|
||||
|
||||
assert.NotNil(t, ioOp, "should return non-nil IO operation")
|
||||
result := io.Run(ioOp)
|
||||
assert.NotNil(t, result, "IO operation should execute successfully")
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoggingMetricsCanary tests the Canary method
|
||||
func TestLoggingMetricsCanary(t *testing.T) {
|
||||
t.Run("logs canary event with correct format", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
|
||||
|
||||
io.Run(metrics.Canary(timestamp))
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "Canary:", "should contain Canary prefix")
|
||||
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
|
||||
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
|
||||
})
|
||||
|
||||
t.Run("returns IO[Void] that can be executed", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
timestamp := time.Now()
|
||||
|
||||
ioOp := metrics.Canary(timestamp)
|
||||
|
||||
assert.NotNil(t, ioOp, "should return non-nil IO operation")
|
||||
result := io.Run(ioOp)
|
||||
assert.NotNil(t, result, "IO operation should execute successfully")
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoggingMetricsDoLog tests the doLog helper method
|
||||
func TestLoggingMetricsDoLog(t *testing.T) {
|
||||
t.Run("formats log message correctly", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := &loggingMetrics{name: "TestCircuit", logger: logger}
|
||||
timestamp := time.Date(2026, 1, 9, 15, 30, 45, 0, time.UTC)
|
||||
|
||||
io.Run(metrics.doLog("CustomEvent", timestamp))
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "CustomEvent:", "should contain custom prefix")
|
||||
assert.Contains(t, output, "TestCircuit", "should contain circuit name")
|
||||
assert.Contains(t, output, timestamp.String(), "should contain timestamp")
|
||||
})
|
||||
|
||||
t.Run("handles different prefixes", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := &loggingMetrics{name: "TestCircuit", logger: logger}
|
||||
timestamp := time.Now()
|
||||
|
||||
prefixes := []string{"Accept", "Reject", "Open", "Close", "Canary", "Custom"}
|
||||
for _, prefix := range prefixes {
|
||||
buf.Reset()
|
||||
io.Run(metrics.doLog(prefix, timestamp))
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, prefix+":", "should contain prefix: "+prefix)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMetricsIntegration tests integration scenarios
|
||||
func TestMetricsIntegration(t *testing.T) {
|
||||
t.Run("logs complete circuit breaker lifecycle", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("APICircuit", logger)
|
||||
baseTime := time.Date(2026, 1, 9, 15, 30, 0, 0, time.UTC)
|
||||
|
||||
// Simulate circuit breaker lifecycle
|
||||
io.Run(metrics.Accept(baseTime)) // Request accepted
|
||||
io.Run(metrics.Accept(baseTime.Add(1 * time.Second))) // Another request
|
||||
io.Run(metrics.Open(baseTime.Add(2 * time.Second))) // Circuit opens
|
||||
io.Run(metrics.Reject(baseTime.Add(3 * time.Second))) // Request rejected
|
||||
io.Run(metrics.Canary(baseTime.Add(30 * time.Second))) // Canary attempt
|
||||
io.Run(metrics.Close(baseTime.Add(31 * time.Second))) // Circuit closes
|
||||
|
||||
output := buf.String()
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
assert.Len(t, lines, 6, "should have 6 log lines")
|
||||
|
||||
assert.Contains(t, lines[0], "Accept:")
|
||||
assert.Contains(t, lines[1], "Accept:")
|
||||
assert.Contains(t, lines[2], "Open:")
|
||||
assert.Contains(t, lines[3], "Reject:")
|
||||
assert.Contains(t, lines[4], "Canary:")
|
||||
assert.Contains(t, lines[5], "Close:")
|
||||
})
|
||||
|
||||
t.Run("distinguishes between multiple circuit breakers", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics1 := MakeMetricsFromLogger("Circuit1", logger)
|
||||
metrics2 := MakeMetricsFromLogger("Circuit2", logger)
|
||||
timestamp := time.Now()
|
||||
|
||||
io.Run(metrics1.Accept(timestamp))
|
||||
io.Run(metrics2.Accept(timestamp))
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "Circuit1", "should contain first circuit name")
|
||||
assert.Contains(t, output, "Circuit2", "should contain second circuit name")
|
||||
})
|
||||
}
|
||||
|
||||
// TestMetricsThreadSafety tests concurrent access to metrics
|
||||
func TestMetricsThreadSafety(t *testing.T) {
|
||||
t.Run("handles concurrent metric recording", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("ConcurrentCircuit", logger)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
numGoroutines := 100
|
||||
wg.Add(numGoroutines)
|
||||
|
||||
// Launch multiple goroutines recording metrics concurrently
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
timestamp := time.Now()
|
||||
io.Run(metrics.Accept(timestamp))
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
output := buf.String()
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
assert.Len(t, lines, numGoroutines, "should have logged all events")
|
||||
})
|
||||
|
||||
t.Run("handles concurrent different event types", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("ConcurrentCircuit", logger)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
numIterations := 20
|
||||
wg.Add(numIterations * 5) // 5 event types
|
||||
|
||||
timestamp := time.Now()
|
||||
|
||||
for i := 0; i < numIterations; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
io.Run(metrics.Accept(timestamp))
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
io.Run(metrics.Reject(timestamp))
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
io.Run(metrics.Open(timestamp))
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
io.Run(metrics.Close(timestamp))
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
io.Run(metrics.Canary(timestamp))
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
output := buf.String()
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
assert.Len(t, lines, numIterations*5, "should have logged all events")
|
||||
})
|
||||
}
|
||||
|
||||
// TestMetricsEdgeCases tests edge cases and special scenarios
|
||||
func TestMetricsEdgeCases(t *testing.T) {
|
||||
t.Run("handles empty circuit breaker name", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("", logger)
|
||||
timestamp := time.Now()
|
||||
|
||||
io.Run(metrics.Accept(timestamp))
|
||||
|
||||
output := buf.String()
|
||||
assert.NotEmpty(t, output, "should still log even with empty name")
|
||||
})
|
||||
|
||||
t.Run("handles very long circuit breaker name", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
longName := strings.Repeat("VeryLongCircuitBreakerName", 100)
|
||||
metrics := MakeMetricsFromLogger(longName, logger)
|
||||
timestamp := time.Now()
|
||||
|
||||
io.Run(metrics.Accept(timestamp))
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, longName, "should handle long names")
|
||||
})
|
||||
|
||||
t.Run("handles special characters in name", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
specialName := "Circuit-Breaker_123!@#$%^&*()"
|
||||
metrics := MakeMetricsFromLogger(specialName, logger)
|
||||
timestamp := time.Now()
|
||||
|
||||
io.Run(metrics.Accept(timestamp))
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, specialName, "should handle special characters")
|
||||
})
|
||||
|
||||
t.Run("handles zero time", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
zeroTime := time.Time{}
|
||||
|
||||
io.Run(metrics.Accept(zeroTime))
|
||||
|
||||
output := buf.String()
|
||||
assert.NotEmpty(t, output, "should handle zero time")
|
||||
assert.Contains(t, output, "Accept:")
|
||||
})
|
||||
|
||||
t.Run("handles far future time", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
futureTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||
|
||||
io.Run(metrics.Accept(futureTime))
|
||||
|
||||
output := buf.String()
|
||||
assert.NotEmpty(t, output, "should handle far future time")
|
||||
assert.Contains(t, output, "9999")
|
||||
})
|
||||
}
|
||||
|
||||
// TestMetricsWithCustomLogger tests metrics with different logger configurations
|
||||
func TestMetricsWithCustomLogger(t *testing.T) {
|
||||
t.Run("works with logger with custom prefix", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "[CB] ", 0)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
timestamp := time.Now()
|
||||
|
||||
io.Run(metrics.Accept(timestamp))
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "[CB]", "should include custom prefix")
|
||||
assert.Contains(t, output, "Accept:")
|
||||
})
|
||||
|
||||
t.Run("works with logger with flags", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", log.Ldate|log.Ltime)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
timestamp := time.Now()
|
||||
|
||||
io.Run(metrics.Accept(timestamp))
|
||||
|
||||
output := buf.String()
|
||||
assert.NotEmpty(t, output, "should log with flags")
|
||||
assert.Contains(t, output, "Accept:")
|
||||
})
|
||||
}
|
||||
|
||||
// TestMetricsIOOperations tests IO operation behavior
|
||||
func TestMetricsIOOperations(t *testing.T) {
|
||||
t.Run("IO operations are lazy", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
timestamp := time.Now()
|
||||
|
||||
// Create IO operation but don't execute it
|
||||
_ = metrics.Accept(timestamp)
|
||||
|
||||
// Buffer should be empty because IO wasn't executed
|
||||
assert.Empty(t, buf.String(), "IO operation should be lazy")
|
||||
})
|
||||
|
||||
t.Run("IO operations execute when run", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
timestamp := time.Now()
|
||||
|
||||
ioOp := metrics.Accept(timestamp)
|
||||
io.Run(ioOp)
|
||||
|
||||
assert.NotEmpty(t, buf.String(), "IO operation should execute when run")
|
||||
})
|
||||
|
||||
t.Run("same IO operation can be executed multiple times", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := log.New(&buf, "", 0)
|
||||
metrics := MakeMetricsFromLogger("TestCircuit", logger)
|
||||
timestamp := time.Now()
|
||||
|
||||
ioOp := metrics.Accept(timestamp)
|
||||
io.Run(ioOp)
|
||||
io.Run(ioOp)
|
||||
io.Run(ioOp)
|
||||
|
||||
output := buf.String()
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
assert.Len(t, lines, 3, "should execute multiple times")
|
||||
})
|
||||
}
|
||||
118
v2/circuitbreaker/types.go
Normal file
118
v2/circuitbreaker/types.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Package circuitbreaker provides a functional implementation of the circuit breaker pattern.
|
||||
// A circuit breaker prevents cascading failures by temporarily blocking requests to a failing service,
|
||||
// allowing it time to recover before retrying.
|
||||
//
|
||||
// # Thread Safety
|
||||
//
|
||||
// All data structures in this package are immutable except for IORef[BreakerState].
|
||||
// The IORef provides thread-safe mutable state through atomic operations.
|
||||
//
|
||||
// Immutable types (safe for concurrent use):
|
||||
// - BreakerState (Either[openState, ClosedState])
|
||||
// - openState
|
||||
// - ClosedState implementations (closedStateWithErrorCount, closedStateWithHistory)
|
||||
// - All function types and readers
|
||||
//
|
||||
// Mutable types (thread-safe through atomic operations):
|
||||
// - IORef[BreakerState] - provides atomic read/write/modify operations
|
||||
//
|
||||
// ClosedState implementations must be thread-safe. The recommended approach is to
|
||||
// return new copies for all operations (Empty, AddError, AddSuccess, Check), which
|
||||
// provides automatic thread safety through immutability.
|
||||
package circuitbreaker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/IBM/fp-go/v2/either"
|
||||
"github.com/IBM/fp-go/v2/endomorphism"
|
||||
"github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
"github.com/IBM/fp-go/v2/ioref"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/ord"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
"github.com/IBM/fp-go/v2/predicate"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
"github.com/IBM/fp-go/v2/retry"
|
||||
"github.com/IBM/fp-go/v2/state"
|
||||
)
|
||||
|
||||
type (
|
||||
// Ord is a type alias for ord.Ord, representing a total ordering on type A.
|
||||
// Used for comparing values in a consistent way.
|
||||
Ord[A any] = ord.Ord[A]
|
||||
|
||||
// Option is a type alias for option.Option, representing an optional value.
|
||||
// It can be either Some(value) or None, used for safe handling of nullable values.
|
||||
Option[A any] = option.Option[A]
|
||||
|
||||
// Endomorphism is a type alias for endomorphism.Endomorphism, representing a function from A to A.
|
||||
// Used for transformations that preserve the type.
|
||||
Endomorphism[A any] = endomorphism.Endomorphism[A]
|
||||
|
||||
// IO is a type alias for io.IO, representing a lazy computation that produces a value of type T.
|
||||
// Used for side-effectful operations that are deferred until execution.
|
||||
IO[T any] = io.IO[T]
|
||||
|
||||
// Pair is a type alias for pair.Pair, representing a tuple of two values.
|
||||
// Used for grouping related values together.
|
||||
Pair[L, R any] = pair.Pair[L, R]
|
||||
|
||||
// IORef is a type alias for ioref.IORef, representing a mutable reference to a value of type T.
|
||||
// Used for managing mutable state in a functional way with IO operations.
|
||||
IORef[T any] = ioref.IORef[T]
|
||||
|
||||
// State is a type alias for state.State, representing a stateful computation.
|
||||
// It transforms a state of type T and produces a result of type R.
|
||||
State[T, R any] = state.State[T, R]
|
||||
|
||||
// Either is a type alias for either.Either, representing a value that can be one of two types.
|
||||
// Left[E] represents an error or alternative path, Right[A] represents the success path.
|
||||
Either[E, A any] = either.Either[E, A]
|
||||
|
||||
// Predicate is a type alias for predicate.Predicate, representing a function that tests a value.
|
||||
// Returns true if the value satisfies the predicate condition, false otherwise.
|
||||
Predicate[A any] = predicate.Predicate[A]
|
||||
|
||||
// Reader is a type alias for reader.Reader, representing a computation that depends on an environment R
|
||||
// and produces a value of type A. Used for dependency injection and configuration.
|
||||
Reader[R, A any] = reader.Reader[R, A]
|
||||
|
||||
// openState represents the internal state when the circuit breaker is open.
|
||||
// In the open state, requests are blocked to give the failing service time to recover.
|
||||
// The circuit breaker will transition to a half-open state (canary request) after resetAt.
|
||||
openState struct {
|
||||
openedAt time.Time
|
||||
|
||||
// resetAt is the time when the circuit breaker should attempt a canary request
|
||||
// to test if the service has recovered. Calculated based on the retry policy.
|
||||
resetAt time.Time
|
||||
|
||||
// retryStatus tracks the current retry attempt information, including the number
|
||||
// of retries and the delay between attempts. Used by the retry policy to calculate
|
||||
// exponential backoff or other retry strategies.
|
||||
retryStatus retry.RetryStatus
|
||||
|
||||
// canaryRequest indicates whether the circuit is in half-open state, allowing
|
||||
// a single test request (canary) to check if the service has recovered.
|
||||
// If true, one request is allowed through to test the service.
|
||||
// If the canary succeeds, the circuit closes; if it fails, the circuit remains open
|
||||
// with an extended reset time.
|
||||
canaryRequest bool
|
||||
}
|
||||
|
||||
// BreakerState represents the current state of the circuit breaker.
|
||||
// It is an Either type where:
|
||||
// - Left[openState] represents an open circuit (requests are blocked)
|
||||
// - Right[ClosedState] represents a closed circuit (requests are allowed through)
|
||||
//
|
||||
// State Transitions:
|
||||
// - Closed -> Open: When failure threshold is exceeded in ClosedState
|
||||
// - Open -> Half-Open: When resetAt is reached (canaryRequest = true)
|
||||
// - Half-Open -> Closed: When canary request succeeds
|
||||
// - Half-Open -> Open: When canary request fails (with extended resetAt)
|
||||
BreakerState = Either[openState, ClosedState]
|
||||
|
||||
Void = function.Void
|
||||
)
|
||||
169
v2/cli/lens.go
169
v2/cli/lens.go
@@ -27,13 +27,15 @@ import (
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
S "github.com/IBM/fp-go/v2/string"
|
||||
C "github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
keyLensDir = "dir"
|
||||
keyVerbose = "verbose"
|
||||
lensAnnotation = "fp-go:Lens"
|
||||
keyLensDir = "dir"
|
||||
keyVerbose = "verbose"
|
||||
keyIncludeTestFile = "include-test-files"
|
||||
lensAnnotation = "fp-go:Lens"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -49,6 +51,13 @@ var (
|
||||
Value: false,
|
||||
Usage: "Enable verbose output",
|
||||
}
|
||||
|
||||
flagIncludeTestFiles = &C.BoolFlag{
|
||||
Name: keyIncludeTestFile,
|
||||
Aliases: []string{"t"},
|
||||
Value: false,
|
||||
Usage: "Include test files (*_test.go) when scanning for annotated types",
|
||||
}
|
||||
)
|
||||
|
||||
// structInfo holds information about a struct that needs lens generation
|
||||
@@ -67,6 +76,7 @@ type fieldInfo struct {
|
||||
BaseType string // TypeName without leading * for pointer types
|
||||
IsOptional bool // true if field is a pointer or has json omitempty tag
|
||||
IsComparable bool // true if the type is comparable (can use ==)
|
||||
IsEmbedded bool // true if this field comes from an embedded struct
|
||||
}
|
||||
|
||||
// templateData holds data for template rendering
|
||||
@@ -80,12 +90,12 @@ const lensStructTemplate = `
|
||||
type {{.Name}}Lenses{{.TypeParams}} struct {
|
||||
// mandatory fields
|
||||
{{- range .Fields}}
|
||||
{{.Name}} L.Lens[{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
|
||||
{{.Name}} __lens.Lens[{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
|
||||
{{- end}}
|
||||
// optional fields
|
||||
{{- range .Fields}}
|
||||
{{- if .IsComparable}}
|
||||
{{.Name}}O LO.LensO[{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
|
||||
{{.Name}}O __lens_option.LensO[{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
@@ -94,13 +104,24 @@ type {{.Name}}Lenses{{.TypeParams}} struct {
|
||||
type {{.Name}}RefLenses{{.TypeParams}} struct {
|
||||
// mandatory fields
|
||||
{{- range .Fields}}
|
||||
{{.Name}} L.Lens[*{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
|
||||
{{.Name}} __lens.Lens[*{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
|
||||
{{- end}}
|
||||
// optional fields
|
||||
{{- range .Fields}}
|
||||
{{- if .IsComparable}}
|
||||
{{.Name}}O LO.LensO[*{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
|
||||
{{.Name}}O __lens_option.LensO[*{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
// prisms
|
||||
{{- range .Fields}}
|
||||
{{.Name}}P __prism.Prism[*{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
|
||||
{{- end}}
|
||||
}
|
||||
|
||||
// {{.Name}}Prisms provides prisms for accessing fields of {{.Name}}
|
||||
type {{.Name}}Prisms{{.TypeParams}} struct {
|
||||
{{- range .Fields}}
|
||||
{{.Name}} __prism.Prism[{{$.Name}}{{$.TypeParamNames}}, {{.TypeName}}]
|
||||
{{- end}}
|
||||
}
|
||||
`
|
||||
@@ -110,15 +131,16 @@ const lensConstructorTemplate = `
|
||||
func Make{{.Name}}Lenses{{.TypeParams}}() {{.Name}}Lenses{{.TypeParamNames}} {
|
||||
// mandatory lenses
|
||||
{{- range .Fields}}
|
||||
lens{{.Name}} := L.MakeLens(
|
||||
lens{{.Name}} := __lens.MakeLensWithName(
|
||||
func(s {{$.Name}}{{$.TypeParamNames}}) {{.TypeName}} { return s.{{.Name}} },
|
||||
func(s {{$.Name}}{{$.TypeParamNames}}, v {{.TypeName}}) {{$.Name}}{{$.TypeParamNames}} { s.{{.Name}} = v; return s },
|
||||
"{{$.Name}}{{$.TypeParamNames}}.{{.Name}}",
|
||||
)
|
||||
{{- end}}
|
||||
// optional lenses
|
||||
{{- range .Fields}}
|
||||
{{- if .IsComparable}}
|
||||
lens{{.Name}}O := LO.FromIso[{{$.Name}}{{$.TypeParamNames}}](IO.FromZero[{{.TypeName}}]())(lens{{.Name}})
|
||||
lens{{.Name}}O := __lens_option.FromIso[{{$.Name}}{{$.TypeParamNames}}](__iso_option.FromZero[{{.TypeName}}]())(lens{{.Name}})
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
return {{.Name}}Lenses{{.TypeParamNames}}{
|
||||
@@ -140,21 +162,23 @@ func Make{{.Name}}RefLenses{{.TypeParams}}() {{.Name}}RefLenses{{.TypeParamNames
|
||||
// mandatory lenses
|
||||
{{- range .Fields}}
|
||||
{{- if .IsComparable}}
|
||||
lens{{.Name}} := L.MakeLensStrict(
|
||||
lens{{.Name}} := __lens.MakeLensStrictWithName(
|
||||
func(s *{{$.Name}}{{$.TypeParamNames}}) {{.TypeName}} { return s.{{.Name}} },
|
||||
func(s *{{$.Name}}{{$.TypeParamNames}}, v {{.TypeName}}) *{{$.Name}}{{$.TypeParamNames}} { s.{{.Name}} = v; return s },
|
||||
"(*{{$.Name}}{{$.TypeParamNames}}).{{.Name}}",
|
||||
)
|
||||
{{- else}}
|
||||
lens{{.Name}} := L.MakeLensRef(
|
||||
lens{{.Name}} := __lens.MakeLensRefWithName(
|
||||
func(s *{{$.Name}}{{$.TypeParamNames}}) {{.TypeName}} { return s.{{.Name}} },
|
||||
func(s *{{$.Name}}{{$.TypeParamNames}}, v {{.TypeName}}) *{{$.Name}}{{$.TypeParamNames}} { s.{{.Name}} = v; return s },
|
||||
"(*{{$.Name}}{{$.TypeParamNames}}).{{.Name}}",
|
||||
)
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
// optional lenses
|
||||
{{- range .Fields}}
|
||||
{{- if .IsComparable}}
|
||||
lens{{.Name}}O := LO.FromIso[*{{$.Name}}{{$.TypeParamNames}}](IO.FromZero[{{.TypeName}}]())(lens{{.Name}})
|
||||
lens{{.Name}}O := __lens_option.FromIso[*{{$.Name}}{{$.TypeParamNames}}](__iso_option.FromZero[{{.TypeName}}]())(lens{{.Name}})
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
return {{.Name}}RefLenses{{.TypeParamNames}}{
|
||||
@@ -170,6 +194,47 @@ func Make{{.Name}}RefLenses{{.TypeParams}}() {{.Name}}RefLenses{{.TypeParamNames
|
||||
{{- end}}
|
||||
}
|
||||
}
|
||||
|
||||
// Make{{.Name}}Prisms creates a new {{.Name}}Prisms with prisms for all fields
|
||||
func Make{{.Name}}Prisms{{.TypeParams}}() {{.Name}}Prisms{{.TypeParamNames}} {
|
||||
{{- range .Fields}}
|
||||
{{- if .IsComparable}}
|
||||
_fromNonZero{{.Name}} := __option.FromNonZero[{{.TypeName}}]()
|
||||
_prism{{.Name}} := __prism.MakePrismWithName(
|
||||
func(s {{$.Name}}{{$.TypeParamNames}}) __option.Option[{{.TypeName}}] { return _fromNonZero{{.Name}}(s.{{.Name}}) },
|
||||
func(v {{.TypeName}}) {{$.Name}}{{$.TypeParamNames}} {
|
||||
{{- if .IsEmbedded}}
|
||||
var result {{$.Name}}{{$.TypeParamNames}}
|
||||
result.{{.Name}} = v
|
||||
return result
|
||||
{{- else}}
|
||||
return {{$.Name}}{{$.TypeParamNames}}{ {{.Name}}: v }
|
||||
{{- end}}
|
||||
},
|
||||
"{{$.Name}}{{$.TypeParamNames}}.{{.Name}}",
|
||||
)
|
||||
{{- else}}
|
||||
_prism{{.Name}} := __prism.MakePrismWithName(
|
||||
func(s {{$.Name}}{{$.TypeParamNames}}) __option.Option[{{.TypeName}}] { return __option.Some(s.{{.Name}}) },
|
||||
func(v {{.TypeName}}) {{$.Name}}{{$.TypeParamNames}} {
|
||||
{{- if .IsEmbedded}}
|
||||
var result {{$.Name}}{{$.TypeParamNames}}
|
||||
result.{{.Name}} = v
|
||||
return result
|
||||
{{- else}}
|
||||
return {{$.Name}}{{$.TypeParamNames}}{ {{.Name}}: v }
|
||||
{{- end}}
|
||||
},
|
||||
"{{$.Name}}{{$.TypeParamNames}}.{{.Name}}",
|
||||
)
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
return {{.Name}}Prisms{{.TypeParamNames}} {
|
||||
{{- range .Fields}}
|
||||
{{.Name}}: _prism{{.Name}},
|
||||
{{- end}}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var (
|
||||
@@ -439,7 +504,7 @@ func extractEmbeddedFields(embedType ast.Expr, fileImports map[string]string, fi
|
||||
return results
|
||||
}
|
||||
|
||||
if typeName == "" || typeIdent == nil {
|
||||
if S.IsEmpty(typeName) || typeIdent == nil {
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -494,6 +559,7 @@ func extractEmbeddedFields(embedType ast.Expr, fileImports map[string]string, fi
|
||||
BaseType: baseType,
|
||||
IsOptional: isOptional,
|
||||
IsComparable: isComparable,
|
||||
IsEmbedded: true,
|
||||
},
|
||||
fieldType: field.Type,
|
||||
})
|
||||
@@ -695,7 +761,7 @@ func parseFile(filename string) ([]structInfo, string, error) {
|
||||
}
|
||||
|
||||
// generateLensHelpers scans a directory for Go files and generates lens code
|
||||
func generateLensHelpers(dir, filename string, verbose bool) error {
|
||||
func generateLensHelpers(dir, filename string, verbose, includeTestFiles bool) error {
|
||||
// Get absolute path
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
@@ -716,21 +782,34 @@ func generateLensHelpers(dir, filename string, verbose bool) error {
|
||||
log.Printf("Found %d Go files", len(files))
|
||||
}
|
||||
|
||||
// Parse all files and collect structs
|
||||
var allStructs []structInfo
|
||||
// Parse all files and collect structs, separating test and non-test files
|
||||
var regularStructs []structInfo
|
||||
var testStructs []structInfo
|
||||
var packageName string
|
||||
|
||||
for _, file := range files {
|
||||
// Skip generated files and test files
|
||||
if strings.HasSuffix(file, "_test.go") || strings.Contains(file, "gen.go") {
|
||||
baseName := filepath.Base(file)
|
||||
|
||||
// Skip generated lens files (both regular and test)
|
||||
if strings.HasPrefix(baseName, "gen_lens") && strings.HasSuffix(baseName, ".go") {
|
||||
if verbose {
|
||||
log.Printf("Skipping file: %s", filepath.Base(file))
|
||||
log.Printf("Skipping generated lens file: %s", baseName)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
isTestFile := strings.HasSuffix(file, "_test.go")
|
||||
|
||||
// Skip test files unless includeTestFiles is true
|
||||
if isTestFile && !includeTestFiles {
|
||||
if verbose {
|
||||
log.Printf("Skipping test file: %s", baseName)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if verbose {
|
||||
log.Printf("Parsing file: %s", filepath.Base(file))
|
||||
log.Printf("Parsing file: %s", baseName)
|
||||
}
|
||||
|
||||
structs, pkg, err := parseFile(file)
|
||||
@@ -740,27 +819,52 @@ func generateLensHelpers(dir, filename string, verbose bool) error {
|
||||
}
|
||||
|
||||
if verbose && len(structs) > 0 {
|
||||
log.Printf("Found %d annotated struct(s) in %s", len(structs), filepath.Base(file))
|
||||
log.Printf("Found %d annotated struct(s) in %s", len(structs), baseName)
|
||||
for _, s := range structs {
|
||||
log.Printf(" - %s (%d fields)", s.Name, len(s.Fields))
|
||||
}
|
||||
}
|
||||
|
||||
if packageName == "" {
|
||||
if S.IsEmpty(packageName) {
|
||||
packageName = pkg
|
||||
}
|
||||
|
||||
allStructs = append(allStructs, structs...)
|
||||
// Separate structs based on source file type
|
||||
if isTestFile {
|
||||
testStructs = append(testStructs, structs...)
|
||||
} else {
|
||||
regularStructs = append(regularStructs, structs...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(allStructs) == 0 {
|
||||
if len(regularStructs) == 0 && len(testStructs) == 0 {
|
||||
log.Printf("No structs with %s annotation found in %s", lensAnnotation, absDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate regular lens file if there are regular structs
|
||||
if len(regularStructs) > 0 {
|
||||
if err := generateLensFile(absDir, filename, packageName, regularStructs, verbose); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Generate test lens file if there are test structs
|
||||
if len(testStructs) > 0 {
|
||||
testFilename := strings.TrimSuffix(filename, ".go") + "_test.go"
|
||||
if err := generateLensFile(absDir, testFilename, packageName, testStructs, verbose); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateLensFile generates a lens file for the given structs
|
||||
func generateLensFile(absDir, filename, packageName string, structs []structInfo, verbose bool) error {
|
||||
// Collect all unique imports from all structs
|
||||
allImports := make(map[string]string) // import path -> alias
|
||||
for _, s := range allStructs {
|
||||
for _, s := range structs {
|
||||
for importPath, alias := range s.Imports {
|
||||
allImports[importPath] = alias
|
||||
}
|
||||
@@ -774,7 +878,7 @@ func generateLensHelpers(dir, filename string, verbose bool) error {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
log.Printf("Generating lens code in [%s] for package [%s] with [%d] structs ...", outPath, packageName, len(allStructs))
|
||||
log.Printf("Generating lens code in [%s] for package [%s] with [%d] structs ...", outPath, packageName, len(structs))
|
||||
|
||||
// Write header
|
||||
writePackage(f, packageName)
|
||||
@@ -782,10 +886,11 @@ func generateLensHelpers(dir, filename string, verbose bool) error {
|
||||
// Write imports
|
||||
f.WriteString("import (\n")
|
||||
// Standard fp-go imports always needed
|
||||
f.WriteString("\tL \"github.com/IBM/fp-go/v2/optics/lens\"\n")
|
||||
f.WriteString("\tLO \"github.com/IBM/fp-go/v2/optics/lens/option\"\n")
|
||||
// f.WriteString("\tO \"github.com/IBM/fp-go/v2/option\"\n")
|
||||
f.WriteString("\tIO \"github.com/IBM/fp-go/v2/optics/iso/option\"\n")
|
||||
f.WriteString("\t__lens \"github.com/IBM/fp-go/v2/optics/lens\"\n")
|
||||
f.WriteString("\t__option \"github.com/IBM/fp-go/v2/option\"\n")
|
||||
f.WriteString("\t__prism \"github.com/IBM/fp-go/v2/optics/prism\"\n")
|
||||
f.WriteString("\t__lens_option \"github.com/IBM/fp-go/v2/optics/lens/option\"\n")
|
||||
f.WriteString("\t__iso_option \"github.com/IBM/fp-go/v2/optics/iso/option\"\n")
|
||||
|
||||
// Add additional imports collected from field types
|
||||
for importPath, alias := range allImports {
|
||||
@@ -795,7 +900,7 @@ func generateLensHelpers(dir, filename string, verbose bool) error {
|
||||
f.WriteString(")\n")
|
||||
|
||||
// Generate lens code for each struct using templates
|
||||
for _, s := range allStructs {
|
||||
for _, s := range structs {
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Generate struct type
|
||||
@@ -827,12 +932,14 @@ func LensCommand() *C.Command {
|
||||
flagLensDir,
|
||||
flagFilename,
|
||||
flagVerbose,
|
||||
flagIncludeTestFiles,
|
||||
},
|
||||
Action: func(ctx *C.Context) error {
|
||||
return generateLensHelpers(
|
||||
ctx.String(keyLensDir),
|
||||
ctx.String(keyFilename),
|
||||
ctx.Bool(keyVerbose),
|
||||
ctx.Bool(keyIncludeTestFile),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
S "github.com/IBM/fp-go/v2/string"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -60,7 +61,7 @@ func TestHasLensAnnotation(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var doc *ast.CommentGroup
|
||||
if tt.comment != "" {
|
||||
if S.IsNonEmpty(tt.comment) {
|
||||
doc = &ast.CommentGroup{
|
||||
List: []*ast.Comment{
|
||||
{Text: tt.comment},
|
||||
@@ -289,7 +290,7 @@ func TestHasOmitEmpty(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var tag *ast.BasicLit
|
||||
if tt.tag != "" {
|
||||
if S.IsNonEmpty(tt.tag) {
|
||||
tag = &ast.BasicLit{
|
||||
Value: tt.tag,
|
||||
}
|
||||
@@ -326,7 +327,7 @@ type Other struct {
|
||||
}
|
||||
`
|
||||
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0644)
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse the file
|
||||
@@ -380,7 +381,7 @@ type Config struct {
|
||||
}
|
||||
`
|
||||
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0644)
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse the file
|
||||
@@ -440,7 +441,7 @@ type TypeTest struct {
|
||||
}
|
||||
`
|
||||
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0644)
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse the file
|
||||
@@ -514,16 +515,16 @@ func TestLensRefTemplatesWithComparable(t *testing.T) {
|
||||
assert.Contains(t, constructorStr, "func MakeTestStructRefLenses() TestStructRefLenses")
|
||||
|
||||
// Name field - comparable, should use MakeLensStrict
|
||||
assert.Contains(t, constructorStr, "lensName := L.MakeLensStrict(",
|
||||
"comparable field Name should use MakeLensStrict in RefLenses")
|
||||
assert.Contains(t, constructorStr, "lensName := __lens.MakeLensStrictWithName(",
|
||||
"comparable field Name should use MakeLensStrictWithName in RefLenses")
|
||||
|
||||
// Age field - comparable, should use MakeLensStrict
|
||||
assert.Contains(t, constructorStr, "lensAge := L.MakeLensStrict(",
|
||||
"comparable field Age should use MakeLensStrict in RefLenses")
|
||||
assert.Contains(t, constructorStr, "lensAge := __lens.MakeLensStrictWithName(",
|
||||
"comparable field Age should use MakeLensStrictWithName in RefLenses")
|
||||
|
||||
// Data field - not comparable, should use MakeLensRef
|
||||
assert.Contains(t, constructorStr, "lensData := L.MakeLensRef(",
|
||||
"non-comparable field Data should use MakeLensRef in RefLenses")
|
||||
assert.Contains(t, constructorStr, "lensData := __lens.MakeLensRefWithName(",
|
||||
"non-comparable field Data should use MakeLensRefWithName in RefLenses")
|
||||
|
||||
}
|
||||
|
||||
@@ -542,12 +543,12 @@ type TestStruct struct {
|
||||
`
|
||||
|
||||
testFile := filepath.Join(tmpDir, "test.go")
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0644)
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate lens code
|
||||
outputFile := "gen.go"
|
||||
err = generateLensHelpers(tmpDir, outputFile, false)
|
||||
err = generateLensHelpers(tmpDir, outputFile, false, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the generated file exists
|
||||
@@ -564,23 +565,23 @@ type TestStruct struct {
|
||||
// Check for expected content in RefLenses
|
||||
assert.Contains(t, contentStr, "MakeTestStructRefLenses")
|
||||
|
||||
// Name and Count are comparable, should use MakeLensStrict
|
||||
assert.Contains(t, contentStr, "L.MakeLensStrict",
|
||||
"comparable fields should use MakeLensStrict in RefLenses")
|
||||
// Name and Count are comparable, should use MakeLensStrictWithName
|
||||
assert.Contains(t, contentStr, "__lens.MakeLensStrictWithName",
|
||||
"comparable fields should use MakeLensStrictWithName in RefLenses")
|
||||
|
||||
// Data is not comparable (slice), should use MakeLensRef
|
||||
assert.Contains(t, contentStr, "L.MakeLensRef",
|
||||
"non-comparable fields should use MakeLensRef in RefLenses")
|
||||
// Data is not comparable (slice), should use MakeLensRefWithName
|
||||
assert.Contains(t, contentStr, "__lens.MakeLensRefWithName",
|
||||
"non-comparable fields should use MakeLensRefWithName in RefLenses")
|
||||
|
||||
// Verify the pattern appears for Name field (comparable)
|
||||
namePattern := "lensName := L.MakeLensStrict("
|
||||
namePattern := "lensName := __lens.MakeLensStrictWithName("
|
||||
assert.Contains(t, contentStr, namePattern,
|
||||
"Name field should use MakeLensStrict")
|
||||
"Name field should use MakeLensStrictWithName")
|
||||
|
||||
// Verify the pattern appears for Data field (not comparable)
|
||||
dataPattern := "lensData := L.MakeLensRef("
|
||||
dataPattern := "lensData := __lens.MakeLensRefWithName("
|
||||
assert.Contains(t, contentStr, dataPattern,
|
||||
"Data field should use MakeLensRef")
|
||||
"Data field should use MakeLensRefWithName")
|
||||
}
|
||||
|
||||
func TestGenerateLensHelpers(t *testing.T) {
|
||||
@@ -597,12 +598,12 @@ type TestStruct struct {
|
||||
`
|
||||
|
||||
testFile := filepath.Join(tmpDir, "test.go")
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0644)
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate lens code
|
||||
outputFile := "gen.go"
|
||||
err = generateLensHelpers(tmpDir, outputFile, false)
|
||||
err = generateLensHelpers(tmpDir, outputFile, false, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the generated file exists
|
||||
@@ -621,9 +622,9 @@ type TestStruct struct {
|
||||
assert.Contains(t, contentStr, "Code generated by go generate")
|
||||
assert.Contains(t, contentStr, "TestStructLenses")
|
||||
assert.Contains(t, contentStr, "MakeTestStructLenses")
|
||||
assert.Contains(t, contentStr, "L.Lens[TestStruct, string]")
|
||||
assert.Contains(t, contentStr, "LO.LensO[TestStruct, *int]")
|
||||
assert.Contains(t, contentStr, "IO.FromZero")
|
||||
assert.Contains(t, contentStr, "__lens.Lens[TestStruct, string]")
|
||||
assert.Contains(t, contentStr, "__lens_option.LensO[TestStruct, *int]")
|
||||
assert.Contains(t, contentStr, "__iso_option.FromZero")
|
||||
}
|
||||
|
||||
func TestGenerateLensHelpersNoAnnotations(t *testing.T) {
|
||||
@@ -639,12 +640,12 @@ type TestStruct struct {
|
||||
`
|
||||
|
||||
testFile := filepath.Join(tmpDir, "test.go")
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0644)
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate lens code (should not create file)
|
||||
outputFile := "gen.go"
|
||||
err = generateLensHelpers(tmpDir, outputFile, false)
|
||||
err = generateLensHelpers(tmpDir, outputFile, false, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the generated file does not exist
|
||||
@@ -669,10 +670,10 @@ func TestLensTemplates(t *testing.T) {
|
||||
|
||||
structStr := structBuf.String()
|
||||
assert.Contains(t, structStr, "type TestStructLenses struct")
|
||||
assert.Contains(t, structStr, "Name L.Lens[TestStruct, string]")
|
||||
assert.Contains(t, structStr, "NameO LO.LensO[TestStruct, string]")
|
||||
assert.Contains(t, structStr, "Value L.Lens[TestStruct, *int]")
|
||||
assert.Contains(t, structStr, "ValueO LO.LensO[TestStruct, *int]")
|
||||
assert.Contains(t, structStr, "Name __lens.Lens[TestStruct, string]")
|
||||
assert.Contains(t, structStr, "NameO __lens_option.LensO[TestStruct, string]")
|
||||
assert.Contains(t, structStr, "Value __lens.Lens[TestStruct, *int]")
|
||||
assert.Contains(t, structStr, "ValueO __lens_option.LensO[TestStruct, *int]")
|
||||
|
||||
// Test constructor template
|
||||
var constructorBuf bytes.Buffer
|
||||
@@ -686,7 +687,7 @@ func TestLensTemplates(t *testing.T) {
|
||||
assert.Contains(t, constructorStr, "NameO: lensNameO,")
|
||||
assert.Contains(t, constructorStr, "Value: lensValue,")
|
||||
assert.Contains(t, constructorStr, "ValueO: lensValueO,")
|
||||
assert.Contains(t, constructorStr, "IO.FromZero")
|
||||
assert.Contains(t, constructorStr, "__iso_option.FromZero")
|
||||
}
|
||||
|
||||
func TestLensTemplatesWithOmitEmpty(t *testing.T) {
|
||||
@@ -707,14 +708,14 @@ func TestLensTemplatesWithOmitEmpty(t *testing.T) {
|
||||
|
||||
structStr := structBuf.String()
|
||||
assert.Contains(t, structStr, "type ConfigStructLenses struct")
|
||||
assert.Contains(t, structStr, "Name L.Lens[ConfigStruct, string]")
|
||||
assert.Contains(t, structStr, "NameO LO.LensO[ConfigStruct, string]")
|
||||
assert.Contains(t, structStr, "Value L.Lens[ConfigStruct, string]")
|
||||
assert.Contains(t, structStr, "ValueO LO.LensO[ConfigStruct, string]", "comparable non-pointer with omitempty should have optional lens")
|
||||
assert.Contains(t, structStr, "Count L.Lens[ConfigStruct, int]")
|
||||
assert.Contains(t, structStr, "CountO LO.LensO[ConfigStruct, int]", "comparable non-pointer with omitempty should have optional lens")
|
||||
assert.Contains(t, structStr, "Pointer L.Lens[ConfigStruct, *string]")
|
||||
assert.Contains(t, structStr, "PointerO LO.LensO[ConfigStruct, *string]")
|
||||
assert.Contains(t, structStr, "Name __lens.Lens[ConfigStruct, string]")
|
||||
assert.Contains(t, structStr, "NameO __lens_option.LensO[ConfigStruct, string]")
|
||||
assert.Contains(t, structStr, "Value __lens.Lens[ConfigStruct, string]")
|
||||
assert.Contains(t, structStr, "ValueO __lens_option.LensO[ConfigStruct, string]", "comparable non-pointer with omitempty should have optional lens")
|
||||
assert.Contains(t, structStr, "Count __lens.Lens[ConfigStruct, int]")
|
||||
assert.Contains(t, structStr, "CountO __lens_option.LensO[ConfigStruct, int]", "comparable non-pointer with omitempty should have optional lens")
|
||||
assert.Contains(t, structStr, "Pointer __lens.Lens[ConfigStruct, *string]")
|
||||
assert.Contains(t, structStr, "PointerO __lens_option.LensO[ConfigStruct, *string]")
|
||||
|
||||
// Test constructor template
|
||||
var constructorBuf bytes.Buffer
|
||||
@@ -723,9 +724,9 @@ func TestLensTemplatesWithOmitEmpty(t *testing.T) {
|
||||
|
||||
constructorStr := constructorBuf.String()
|
||||
assert.Contains(t, constructorStr, "func MakeConfigStructLenses() ConfigStructLenses")
|
||||
assert.Contains(t, constructorStr, "IO.FromZero[string]()")
|
||||
assert.Contains(t, constructorStr, "IO.FromZero[int]()")
|
||||
assert.Contains(t, constructorStr, "IO.FromZero[*string]()")
|
||||
assert.Contains(t, constructorStr, "__iso_option.FromZero[string]()")
|
||||
assert.Contains(t, constructorStr, "__iso_option.FromZero[int]()")
|
||||
assert.Contains(t, constructorStr, "__iso_option.FromZero[*string]()")
|
||||
}
|
||||
|
||||
func TestLensCommandFlags(t *testing.T) {
|
||||
@@ -737,9 +738,9 @@ func TestLensCommandFlags(t *testing.T) {
|
||||
assert.Contains(t, strings.ToLower(cmd.Description), "lenso", "Description should mention LensO for optional lenses")
|
||||
|
||||
// Check flags
|
||||
assert.Len(t, cmd.Flags, 3)
|
||||
assert.Len(t, cmd.Flags, 4)
|
||||
|
||||
var hasDir, hasFilename, hasVerbose bool
|
||||
var hasDir, hasFilename, hasVerbose, hasIncludeTestFiles bool
|
||||
for _, flag := range cmd.Flags {
|
||||
switch flag.Names()[0] {
|
||||
case "dir":
|
||||
@@ -748,12 +749,15 @@ func TestLensCommandFlags(t *testing.T) {
|
||||
hasFilename = true
|
||||
case "verbose":
|
||||
hasVerbose = true
|
||||
case "include-test-files":
|
||||
hasIncludeTestFiles = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, hasDir, "should have dir flag")
|
||||
assert.True(t, hasFilename, "should have filename flag")
|
||||
assert.True(t, hasVerbose, "should have verbose flag")
|
||||
assert.True(t, hasIncludeTestFiles, "should have include-test-files flag")
|
||||
}
|
||||
|
||||
func TestParseFileWithEmbeddedStruct(t *testing.T) {
|
||||
@@ -776,7 +780,7 @@ type Extended struct {
|
||||
}
|
||||
`
|
||||
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0644)
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse the file
|
||||
@@ -824,12 +828,12 @@ type Person struct {
|
||||
`
|
||||
|
||||
testFile := filepath.Join(tmpDir, "test.go")
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0644)
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate lens code
|
||||
outputFile := "gen.go"
|
||||
err = generateLensHelpers(tmpDir, outputFile, false)
|
||||
err = generateLensHelpers(tmpDir, outputFile, false, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the generated file exists
|
||||
@@ -849,14 +853,14 @@ type Person struct {
|
||||
assert.Contains(t, contentStr, "MakePersonLenses")
|
||||
|
||||
// Check that embedded fields are included
|
||||
assert.Contains(t, contentStr, "Street L.Lens[Person, string]", "Should have lens for embedded Street field")
|
||||
assert.Contains(t, contentStr, "City L.Lens[Person, string]", "Should have lens for embedded City field")
|
||||
assert.Contains(t, contentStr, "Name L.Lens[Person, string]", "Should have lens for Name field")
|
||||
assert.Contains(t, contentStr, "Age L.Lens[Person, int]", "Should have lens for Age field")
|
||||
assert.Contains(t, contentStr, "Street __lens.Lens[Person, string]", "Should have lens for embedded Street field")
|
||||
assert.Contains(t, contentStr, "City __lens.Lens[Person, string]", "Should have lens for embedded City field")
|
||||
assert.Contains(t, contentStr, "Name __lens.Lens[Person, string]", "Should have lens for Name field")
|
||||
assert.Contains(t, contentStr, "Age __lens.Lens[Person, int]", "Should have lens for Age field")
|
||||
|
||||
// Check that optional lenses are also generated for embedded fields
|
||||
assert.Contains(t, contentStr, "StreetO LO.LensO[Person, string]")
|
||||
assert.Contains(t, contentStr, "CityO LO.LensO[Person, string]")
|
||||
assert.Contains(t, contentStr, "StreetO __lens_option.LensO[Person, string]")
|
||||
assert.Contains(t, contentStr, "CityO __lens_option.LensO[Person, string]")
|
||||
}
|
||||
|
||||
func TestParseFileWithPointerEmbeddedStruct(t *testing.T) {
|
||||
@@ -880,7 +884,7 @@ type Document struct {
|
||||
}
|
||||
`
|
||||
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0644)
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse the file
|
||||
@@ -922,7 +926,7 @@ type Container[T any] struct {
|
||||
}
|
||||
`
|
||||
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0644)
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse the file
|
||||
@@ -960,7 +964,7 @@ type Pair[K comparable, V any] struct {
|
||||
}
|
||||
`
|
||||
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0644)
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse the file
|
||||
@@ -998,12 +1002,12 @@ type Box[T any] struct {
|
||||
`
|
||||
|
||||
testFile := filepath.Join(tmpDir, "test.go")
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0644)
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate lens code
|
||||
outputFile := "gen.go"
|
||||
err = generateLensHelpers(tmpDir, outputFile, false)
|
||||
err = generateLensHelpers(tmpDir, outputFile, false, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the generated file exists
|
||||
@@ -1025,14 +1029,14 @@ type Box[T any] struct {
|
||||
assert.Contains(t, contentStr, "func MakeBoxRefLenses[T any]() BoxRefLenses[T]", "Should have generic ref constructor")
|
||||
|
||||
// Check that fields use the generic type parameter
|
||||
assert.Contains(t, contentStr, "Content L.Lens[Box[T], T]", "Should have lens for generic Content field")
|
||||
assert.Contains(t, contentStr, "Label L.Lens[Box[T], string]", "Should have lens for Label field")
|
||||
assert.Contains(t, contentStr, "Content __lens.Lens[Box[T], T]", "Should have lens for generic Content field")
|
||||
assert.Contains(t, contentStr, "Label __lens.Lens[Box[T], string]", "Should have lens for Label field")
|
||||
|
||||
// Check optional lenses - only for comparable types
|
||||
// T any is not comparable, so ContentO should NOT be generated
|
||||
assert.NotContains(t, contentStr, "ContentO LO.LensO[Box[T], T]", "T any is not comparable, should not have optional lens")
|
||||
assert.NotContains(t, contentStr, "ContentO __lens_option.LensO[Box[T], T]", "T any is not comparable, should not have optional lens")
|
||||
// string is comparable, so LabelO should be generated
|
||||
assert.Contains(t, contentStr, "LabelO LO.LensO[Box[T], string]", "string is comparable, should have optional lens")
|
||||
assert.Contains(t, contentStr, "LabelO __lens_option.LensO[Box[T], string]", "string is comparable, should have optional lens")
|
||||
}
|
||||
|
||||
func TestGenerateLensHelpersWithComparableTypeParam(t *testing.T) {
|
||||
@@ -1049,12 +1053,12 @@ type ComparableBox[T comparable] struct {
|
||||
`
|
||||
|
||||
testFile := filepath.Join(tmpDir, "test.go")
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0644)
|
||||
err := os.WriteFile(testFile, []byte(testCode), 0o644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate lens code
|
||||
outputFile := "gen.go"
|
||||
err = generateLensHelpers(tmpDir, outputFile, false)
|
||||
err = generateLensHelpers(tmpDir, outputFile, false, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the generated file exists
|
||||
@@ -1074,11 +1078,11 @@ type ComparableBox[T comparable] struct {
|
||||
assert.Contains(t, contentStr, "type ComparableBoxRefLenses[T comparable] struct", "Should have generic ComparableBoxRefLenses type")
|
||||
|
||||
// Check that Key field (with comparable constraint) uses MakeLensStrict in RefLenses
|
||||
assert.Contains(t, contentStr, "lensKey := L.MakeLensStrict(", "Key field with comparable constraint should use MakeLensStrict")
|
||||
assert.Contains(t, contentStr, "lensKey := __lens.MakeLensStrictWithName(", "Key field with comparable constraint should use MakeLensStrictWithName")
|
||||
|
||||
// Check that Value field (string, always comparable) also uses MakeLensStrict
|
||||
assert.Contains(t, contentStr, "lensValue := L.MakeLensStrict(", "Value field (string) should use MakeLensStrict")
|
||||
assert.Contains(t, contentStr, "lensValue := __lens.MakeLensStrictWithName(", "Value field (string) should use MakeLensStrictWithName")
|
||||
|
||||
// Verify that MakeLensRef is NOT used (since both fields are comparable)
|
||||
assert.NotContains(t, contentStr, "L.MakeLensRef(", "Should not use MakeLensRef when all fields are comparable")
|
||||
assert.NotContains(t, contentStr, "__lens.MakeLensRefWithName(", "Should not use MakeLensRefWithName when all fields are comparable")
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
S "github.com/IBM/fp-go/v2/string"
|
||||
)
|
||||
|
||||
// Deprecated:
|
||||
@@ -176,7 +178,7 @@ func generateTraverseTuple1(
|
||||
}
|
||||
fmt.Fprintf(f, "F%d ~func(A%d) %s", j+1, j+1, hkt(fmt.Sprintf("T%d", j+1)))
|
||||
}
|
||||
if infix != "" {
|
||||
if S.IsNonEmpty(infix) {
|
||||
fmt.Fprintf(f, ", %s", infix)
|
||||
}
|
||||
// types
|
||||
@@ -209,7 +211,7 @@ func generateTraverseTuple1(
|
||||
fmt.Fprintf(f, " return A.TraverseTuple%d(\n", i)
|
||||
// map
|
||||
fmt.Fprintf(f, " Map[")
|
||||
if infix != "" {
|
||||
if S.IsNonEmpty(infix) {
|
||||
fmt.Fprintf(f, "%s, T1,", infix)
|
||||
} else {
|
||||
fmt.Fprintf(f, "T1,")
|
||||
@@ -231,7 +233,7 @@ func generateTraverseTuple1(
|
||||
fmt.Fprintf(f, " ")
|
||||
}
|
||||
fmt.Fprintf(f, "%s", tuple)
|
||||
if infix != "" {
|
||||
if S.IsNonEmpty(infix) {
|
||||
fmt.Fprintf(f, ", %s", infix)
|
||||
}
|
||||
fmt.Fprintf(f, ", T%d],\n", j+1)
|
||||
@@ -256,11 +258,11 @@ func generateSequenceTuple1(
|
||||
|
||||
fmt.Fprintf(f, "\n// SequenceTuple%d converts a [Tuple%d] of [%s] into an [%s].\n", i, i, hkt("T"), hkt(fmt.Sprintf("Tuple%d", i)))
|
||||
fmt.Fprintf(f, "func SequenceTuple%d[", i)
|
||||
if infix != "" {
|
||||
if S.IsNonEmpty(infix) {
|
||||
fmt.Fprintf(f, "%s", infix)
|
||||
}
|
||||
for j := 0; j < i; j++ {
|
||||
if infix != "" || j > 0 {
|
||||
if S.IsNonEmpty(infix) || j > 0 {
|
||||
fmt.Fprintf(f, ", ")
|
||||
}
|
||||
fmt.Fprintf(f, "T%d", j+1)
|
||||
@@ -276,7 +278,7 @@ func generateSequenceTuple1(
|
||||
fmt.Fprintf(f, " return A.SequenceTuple%d(\n", i)
|
||||
// map
|
||||
fmt.Fprintf(f, " Map[")
|
||||
if infix != "" {
|
||||
if S.IsNonEmpty(infix) {
|
||||
fmt.Fprintf(f, "%s, T1,", infix)
|
||||
} else {
|
||||
fmt.Fprintf(f, "T1,")
|
||||
@@ -298,7 +300,7 @@ func generateSequenceTuple1(
|
||||
fmt.Fprintf(f, " ")
|
||||
}
|
||||
fmt.Fprintf(f, "%s", tuple)
|
||||
if infix != "" {
|
||||
if S.IsNonEmpty(infix) {
|
||||
fmt.Fprintf(f, ", %s", infix)
|
||||
}
|
||||
fmt.Fprintf(f, ", T%d],\n", j+1)
|
||||
@@ -319,11 +321,11 @@ func generateSequenceT1(
|
||||
|
||||
fmt.Fprintf(f, "\n// SequenceT%d converts %d parameters of [%s] into a [%s].\n", i, i, hkt("T"), hkt(fmt.Sprintf("Tuple%d", i)))
|
||||
fmt.Fprintf(f, "func SequenceT%d[", i)
|
||||
if infix != "" {
|
||||
if S.IsNonEmpty(infix) {
|
||||
fmt.Fprintf(f, "%s", infix)
|
||||
}
|
||||
for j := 0; j < i; j++ {
|
||||
if infix != "" || j > 0 {
|
||||
if S.IsNonEmpty(infix) || j > 0 {
|
||||
fmt.Fprintf(f, ", ")
|
||||
}
|
||||
fmt.Fprintf(f, "T%d", j+1)
|
||||
@@ -339,7 +341,7 @@ func generateSequenceT1(
|
||||
fmt.Fprintf(f, " return A.SequenceT%d(\n", i)
|
||||
// map
|
||||
fmt.Fprintf(f, " Map[")
|
||||
if infix != "" {
|
||||
if S.IsNonEmpty(infix) {
|
||||
fmt.Fprintf(f, "%s, T1,", infix)
|
||||
} else {
|
||||
fmt.Fprintf(f, "T1,")
|
||||
@@ -361,7 +363,7 @@ func generateSequenceT1(
|
||||
fmt.Fprintf(f, " ")
|
||||
}
|
||||
fmt.Fprintf(f, "%s", tuple)
|
||||
if infix != "" {
|
||||
if S.IsNonEmpty(infix) {
|
||||
fmt.Fprintf(f, ", %s", infix)
|
||||
}
|
||||
fmt.Fprintf(f, ", T%d],\n", j+1)
|
||||
|
||||
177
v2/consumer/consumer.go
Normal file
177
v2/consumer/consumer.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package consumer
|
||||
|
||||
// Local transforms a Consumer by preprocessing its input through a function.
|
||||
// This is the contravariant map operation for Consumers, analogous to reader.Local
|
||||
// but operating on the input side rather than the output side.
|
||||
//
|
||||
// Given a Consumer[R1] that consumes values of type R1, and a function f that
|
||||
// converts R2 to R1, Local creates a new Consumer[R2] that:
|
||||
// 1. Takes a value of type R2
|
||||
// 2. Applies f to convert it to R1
|
||||
// 3. Passes the result to the original Consumer[R1]
|
||||
//
|
||||
// This is particularly useful for adapting consumers to work with different input types,
|
||||
// similar to how reader.Local adapts readers to work with different environment types.
|
||||
//
|
||||
// Comparison with reader.Local:
|
||||
// - reader.Local: Transforms the environment BEFORE passing it to a Reader (preprocessing input)
|
||||
// - consumer.Local: Transforms the value BEFORE passing it to a Consumer (preprocessing input)
|
||||
// - Both are contravariant operations on the input type
|
||||
// - Reader produces output, Consumer performs side effects
|
||||
//
|
||||
// Type Parameters:
|
||||
// - R2: The input type of the new Consumer (what you have)
|
||||
// - R1: The input type of the original Consumer (what it expects)
|
||||
//
|
||||
// Parameters:
|
||||
// - f: A function that converts R2 to R1 (preprocessing function)
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that transforms Consumer[R1] into Consumer[R2]
|
||||
//
|
||||
// Example - Basic type adaptation:
|
||||
//
|
||||
// // Consumer that logs integers
|
||||
// logInt := func(x int) {
|
||||
// fmt.Printf("Value: %d\n", x)
|
||||
// }
|
||||
//
|
||||
// // Adapt it to consume strings by parsing them first
|
||||
// parseToInt := func(s string) int {
|
||||
// n, _ := strconv.Atoi(s)
|
||||
// return n
|
||||
// }
|
||||
//
|
||||
// logString := consumer.Local(parseToInt)(logInt)
|
||||
// logString("42") // Logs: "Value: 42"
|
||||
//
|
||||
// Example - Extracting fields from structs:
|
||||
//
|
||||
// type User struct {
|
||||
// Name string
|
||||
// Age int
|
||||
// }
|
||||
//
|
||||
// // Consumer that logs names
|
||||
// logName := func(name string) {
|
||||
// fmt.Printf("Name: %s\n", name)
|
||||
// }
|
||||
//
|
||||
// // Adapt it to consume User structs
|
||||
// extractName := func(u User) string {
|
||||
// return u.Name
|
||||
// }
|
||||
//
|
||||
// logUser := consumer.Local(extractName)(logName)
|
||||
// logUser(User{Name: "Alice", Age: 30}) // Logs: "Name: Alice"
|
||||
//
|
||||
// Example - Simplifying complex types:
|
||||
//
|
||||
// type DetailedConfig struct {
|
||||
// Host string
|
||||
// Port int
|
||||
// Timeout time.Duration
|
||||
// MaxRetry int
|
||||
// }
|
||||
//
|
||||
// type SimpleConfig struct {
|
||||
// Host string
|
||||
// Port int
|
||||
// }
|
||||
//
|
||||
// // Consumer that logs simple configs
|
||||
// logSimple := func(c SimpleConfig) {
|
||||
// fmt.Printf("Server: %s:%d\n", c.Host, c.Port)
|
||||
// }
|
||||
//
|
||||
// // Adapt it to consume detailed configs
|
||||
// simplify := func(d DetailedConfig) SimpleConfig {
|
||||
// return SimpleConfig{Host: d.Host, Port: d.Port}
|
||||
// }
|
||||
//
|
||||
// logDetailed := consumer.Local(simplify)(logSimple)
|
||||
// logDetailed(DetailedConfig{
|
||||
// Host: "localhost",
|
||||
// Port: 8080,
|
||||
// Timeout: time.Second,
|
||||
// MaxRetry: 3,
|
||||
// }) // Logs: "Server: localhost:8080"
|
||||
//
|
||||
// Example - Composing multiple transformations:
|
||||
//
|
||||
// type Response struct {
|
||||
// StatusCode int
|
||||
// Body string
|
||||
// }
|
||||
//
|
||||
// // Consumer that logs status codes
|
||||
// logStatus := func(code int) {
|
||||
// fmt.Printf("Status: %d\n", code)
|
||||
// }
|
||||
//
|
||||
// // Extract status code from response
|
||||
// getStatus := func(r Response) int {
|
||||
// return r.StatusCode
|
||||
// }
|
||||
//
|
||||
// // Adapt to consume responses
|
||||
// logResponse := consumer.Local(getStatus)(logStatus)
|
||||
// logResponse(Response{StatusCode: 200, Body: "OK"}) // Logs: "Status: 200"
|
||||
//
|
||||
// Example - Using with multiple consumers:
|
||||
//
|
||||
// type Event struct {
|
||||
// Type string
|
||||
// Timestamp time.Time
|
||||
// Data map[string]any
|
||||
// }
|
||||
//
|
||||
// // Consumers for different aspects
|
||||
// logType := func(t string) { fmt.Printf("Type: %s\n", t) }
|
||||
// logTime := func(t time.Time) { fmt.Printf("Time: %v\n", t) }
|
||||
//
|
||||
// // Adapt them to consume events
|
||||
// logEventType := consumer.Local(func(e Event) string { return e.Type })(logType)
|
||||
// logEventTime := consumer.Local(func(e Event) time.Time { return e.Timestamp })(logTime)
|
||||
//
|
||||
// event := Event{Type: "UserLogin", Timestamp: time.Now(), Data: nil}
|
||||
// logEventType(event) // Logs: "Type: UserLogin"
|
||||
// logEventTime(event) // Logs: "Time: ..."
|
||||
//
|
||||
// Use Cases:
|
||||
// - Type adaptation: Convert between different input types
|
||||
// - Field extraction: Extract specific fields from complex structures
|
||||
// - Data transformation: Preprocess data before consumption
|
||||
// - Interface adaptation: Adapt consumers to work with different interfaces
|
||||
// - Logging pipelines: Transform data before logging
|
||||
// - Event handling: Extract relevant data from events before processing
|
||||
//
|
||||
// Relationship to Reader:
|
||||
// Consumer is the dual of Reader in category theory:
|
||||
// - Reader[R, A] = R -> A (produces output from environment)
|
||||
// - Consumer[A] = A -> () (consumes input, produces side effects)
|
||||
// - reader.Local transforms the environment before reading
|
||||
// - consumer.Local transforms the input before consuming
|
||||
// - Both are contravariant functors on their input type
|
||||
func Local[R2, R1 any](f func(R2) R1) Operator[R1, R2] {
|
||||
return func(c Consumer[R1]) Consumer[R2] {
|
||||
return func(r2 R2) {
|
||||
c(f(r2))
|
||||
}
|
||||
}
|
||||
}
|
||||
383
v2/consumer/consumer_test.go
Normal file
383
v2/consumer/consumer_test.go
Normal file
@@ -0,0 +1,383 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package consumer
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/IBM/fp-go/v2/function"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLocal(t *testing.T) {
|
||||
t.Run("basic type transformation", func(t *testing.T) {
|
||||
var captured int
|
||||
consumeInt := func(x int) {
|
||||
captured = x
|
||||
}
|
||||
|
||||
// Transform string to int before consuming
|
||||
stringToInt := func(s string) int {
|
||||
n, _ := strconv.Atoi(s)
|
||||
return n
|
||||
}
|
||||
|
||||
consumeString := Local(stringToInt)(consumeInt)
|
||||
consumeString("42")
|
||||
|
||||
assert.Equal(t, 42, captured)
|
||||
})
|
||||
|
||||
t.Run("field extraction from struct", func(t *testing.T) {
|
||||
type User struct {
|
||||
Name string
|
||||
Age int
|
||||
}
|
||||
|
||||
var capturedName string
|
||||
consumeName := func(name string) {
|
||||
capturedName = name
|
||||
}
|
||||
|
||||
extractName := func(u User) string {
|
||||
return u.Name
|
||||
}
|
||||
|
||||
consumeUser := Local(extractName)(consumeName)
|
||||
consumeUser(User{Name: "Alice", Age: 30})
|
||||
|
||||
assert.Equal(t, "Alice", capturedName)
|
||||
})
|
||||
|
||||
t.Run("simplifying complex types", func(t *testing.T) {
|
||||
type DetailedConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Timeout time.Duration
|
||||
MaxRetry int
|
||||
}
|
||||
|
||||
type SimpleConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
|
||||
var captured SimpleConfig
|
||||
consumeSimple := func(c SimpleConfig) {
|
||||
captured = c
|
||||
}
|
||||
|
||||
simplify := func(d DetailedConfig) SimpleConfig {
|
||||
return SimpleConfig{Host: d.Host, Port: d.Port}
|
||||
}
|
||||
|
||||
consumeDetailed := Local(simplify)(consumeSimple)
|
||||
consumeDetailed(DetailedConfig{
|
||||
Host: "localhost",
|
||||
Port: 8080,
|
||||
Timeout: time.Second,
|
||||
MaxRetry: 3,
|
||||
})
|
||||
|
||||
assert.Equal(t, SimpleConfig{Host: "localhost", Port: 8080}, captured)
|
||||
})
|
||||
|
||||
t.Run("multiple transformations", func(t *testing.T) {
|
||||
type Response struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
var capturedStatus int
|
||||
consumeStatus := func(code int) {
|
||||
capturedStatus = code
|
||||
}
|
||||
|
||||
getStatus := func(r Response) int {
|
||||
return r.StatusCode
|
||||
}
|
||||
|
||||
consumeResponse := Local(getStatus)(consumeStatus)
|
||||
consumeResponse(Response{StatusCode: 200, Body: "OK"})
|
||||
|
||||
assert.Equal(t, 200, capturedStatus)
|
||||
})
|
||||
|
||||
t.Run("chaining Local transformations", func(t *testing.T) {
|
||||
type Level3 struct{ Value int }
|
||||
type Level2 struct{ L3 Level3 }
|
||||
type Level1 struct{ L2 Level2 }
|
||||
|
||||
var captured int
|
||||
consumeInt := func(x int) {
|
||||
captured = x
|
||||
}
|
||||
|
||||
// Chain multiple Local transformations
|
||||
extract3 := func(l3 Level3) int { return l3.Value }
|
||||
extract2 := func(l2 Level2) Level3 { return l2.L3 }
|
||||
extract1 := func(l1 Level1) Level2 { return l1.L2 }
|
||||
|
||||
// Compose the transformations
|
||||
consumeLevel3 := Local(extract3)(consumeInt)
|
||||
consumeLevel2 := Local(extract2)(consumeLevel3)
|
||||
consumeLevel1 := Local(extract1)(consumeLevel2)
|
||||
|
||||
consumeLevel1(Level1{L2: Level2{L3: Level3{Value: 42}}})
|
||||
|
||||
assert.Equal(t, 42, captured)
|
||||
})
|
||||
|
||||
t.Run("identity transformation", func(t *testing.T) {
|
||||
var captured string
|
||||
consumeString := func(s string) {
|
||||
captured = s
|
||||
}
|
||||
|
||||
identity := function.Identity[string]
|
||||
|
||||
consumeIdentity := Local(identity)(consumeString)
|
||||
consumeIdentity("test")
|
||||
|
||||
assert.Equal(t, "test", captured)
|
||||
})
|
||||
|
||||
t.Run("transformation with calculation", func(t *testing.T) {
|
||||
type Rectangle struct {
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
var capturedArea int
|
||||
consumeArea := func(area int) {
|
||||
capturedArea = area
|
||||
}
|
||||
|
||||
calculateArea := func(r Rectangle) int {
|
||||
return r.Width * r.Height
|
||||
}
|
||||
|
||||
consumeRectangle := Local(calculateArea)(consumeArea)
|
||||
consumeRectangle(Rectangle{Width: 5, Height: 10})
|
||||
|
||||
assert.Equal(t, 50, capturedArea)
|
||||
})
|
||||
|
||||
t.Run("multiple consumers with same transformation", func(t *testing.T) {
|
||||
type Event struct {
|
||||
Type string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
var capturedType string
|
||||
var capturedTime time.Time
|
||||
|
||||
consumeType := func(t string) {
|
||||
capturedType = t
|
||||
}
|
||||
|
||||
consumeTime := func(t time.Time) {
|
||||
capturedTime = t
|
||||
}
|
||||
|
||||
extractType := func(e Event) string { return e.Type }
|
||||
extractTime := func(e Event) time.Time { return e.Timestamp }
|
||||
|
||||
consumeEventType := Local(extractType)(consumeType)
|
||||
consumeEventTime := Local(extractTime)(consumeTime)
|
||||
|
||||
now := time.Now()
|
||||
event := Event{Type: "UserLogin", Timestamp: now}
|
||||
|
||||
consumeEventType(event)
|
||||
consumeEventTime(event)
|
||||
|
||||
assert.Equal(t, "UserLogin", capturedType)
|
||||
assert.Equal(t, now, capturedTime)
|
||||
})
|
||||
|
||||
t.Run("transformation with slice", func(t *testing.T) {
|
||||
var captured int
|
||||
consumeLength := func(n int) {
|
||||
captured = n
|
||||
}
|
||||
|
||||
getLength := func(s []string) int {
|
||||
return len(s)
|
||||
}
|
||||
|
||||
consumeSlice := Local(getLength)(consumeLength)
|
||||
consumeSlice([]string{"a", "b", "c"})
|
||||
|
||||
assert.Equal(t, 3, captured)
|
||||
})
|
||||
|
||||
t.Run("transformation with map", func(t *testing.T) {
|
||||
var captured int
|
||||
consumeCount := func(n int) {
|
||||
captured = n
|
||||
}
|
||||
|
||||
getCount := func(m map[string]int) int {
|
||||
return len(m)
|
||||
}
|
||||
|
||||
consumeMap := Local(getCount)(consumeCount)
|
||||
consumeMap(map[string]int{"a": 1, "b": 2, "c": 3})
|
||||
|
||||
assert.Equal(t, 3, captured)
|
||||
})
|
||||
|
||||
t.Run("transformation with pointer", func(t *testing.T) {
|
||||
var captured int
|
||||
consumeInt := func(x int) {
|
||||
captured = x
|
||||
}
|
||||
|
||||
dereference := func(p *int) int {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
consumePointer := Local(dereference)(consumeInt)
|
||||
|
||||
value := 42
|
||||
consumePointer(&value)
|
||||
assert.Equal(t, 42, captured)
|
||||
|
||||
consumePointer(nil)
|
||||
assert.Equal(t, 0, captured)
|
||||
})
|
||||
|
||||
t.Run("transformation with custom type", func(t *testing.T) {
|
||||
type MyType struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
var captured string
|
||||
consumeString := func(s string) {
|
||||
captured = s
|
||||
}
|
||||
|
||||
extractValue := func(m MyType) string {
|
||||
return m.Value
|
||||
}
|
||||
|
||||
consumeMyType := Local(extractValue)(consumeString)
|
||||
consumeMyType(MyType{Value: "test"})
|
||||
|
||||
assert.Equal(t, "test", captured)
|
||||
})
|
||||
|
||||
t.Run("accumulation through multiple calls", func(t *testing.T) {
|
||||
var sum int
|
||||
accumulate := func(x int) {
|
||||
sum += x
|
||||
}
|
||||
|
||||
double := func(x int) int {
|
||||
return x * 2
|
||||
}
|
||||
|
||||
accumulateDoubled := Local(double)(accumulate)
|
||||
|
||||
accumulateDoubled(1)
|
||||
accumulateDoubled(2)
|
||||
accumulateDoubled(3)
|
||||
|
||||
assert.Equal(t, 12, sum) // (1*2) + (2*2) + (3*2) = 2 + 4 + 6 = 12
|
||||
})
|
||||
|
||||
t.Run("transformation with error handling", func(t *testing.T) {
|
||||
type Result struct {
|
||||
Value int
|
||||
Error error
|
||||
}
|
||||
|
||||
var captured int
|
||||
consumeInt := func(x int) {
|
||||
captured = x
|
||||
}
|
||||
|
||||
extractValue := func(r Result) int {
|
||||
if r.Error != nil {
|
||||
return -1
|
||||
}
|
||||
return r.Value
|
||||
}
|
||||
|
||||
consumeResult := Local(extractValue)(consumeInt)
|
||||
|
||||
consumeResult(Result{Value: 42, Error: nil})
|
||||
assert.Equal(t, 42, captured)
|
||||
|
||||
consumeResult(Result{Value: 100, Error: assert.AnError})
|
||||
assert.Equal(t, -1, captured)
|
||||
})
|
||||
|
||||
t.Run("transformation preserves consumer behavior", func(t *testing.T) {
|
||||
callCount := 0
|
||||
consumer := func(x int) {
|
||||
callCount++
|
||||
}
|
||||
|
||||
transform := func(s string) int {
|
||||
n, _ := strconv.Atoi(s)
|
||||
return n
|
||||
}
|
||||
|
||||
transformedConsumer := Local(transform)(consumer)
|
||||
|
||||
transformedConsumer("1")
|
||||
transformedConsumer("2")
|
||||
transformedConsumer("3")
|
||||
|
||||
assert.Equal(t, 3, callCount)
|
||||
})
|
||||
|
||||
t.Run("comparison with reader.Local behavior", func(t *testing.T) {
|
||||
// This test demonstrates the dual nature of Consumer and Reader
|
||||
// Consumer: transforms input before consumption (contravariant)
|
||||
// Reader: transforms environment before reading (also contravariant on input)
|
||||
|
||||
type DetailedEnv struct {
|
||||
Value int
|
||||
Extra string
|
||||
}
|
||||
|
||||
type SimpleEnv struct {
|
||||
Value int
|
||||
}
|
||||
|
||||
var captured int
|
||||
consumeSimple := func(e SimpleEnv) {
|
||||
captured = e.Value
|
||||
}
|
||||
|
||||
simplify := func(d DetailedEnv) SimpleEnv {
|
||||
return SimpleEnv{Value: d.Value}
|
||||
}
|
||||
|
||||
consumeDetailed := Local(simplify)(consumeSimple)
|
||||
consumeDetailed(DetailedEnv{Value: 42, Extra: "ignored"})
|
||||
|
||||
assert.Equal(t, 42, captured)
|
||||
})
|
||||
}
|
||||
58
v2/consumer/types.go
Normal file
58
v2/consumer/types.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package consumer provides types and utilities for functions that consume values without returning results.
|
||||
//
|
||||
// A Consumer represents a side-effecting operation that accepts a value but produces no output.
|
||||
// This is useful for operations like logging, printing, updating state, or any action where
|
||||
// the return value is not needed.
|
||||
package consumer
|
||||
|
||||
type (
|
||||
// Consumer represents a function that accepts a value of type A and performs a side effect.
|
||||
// It does not return any value, making it useful for operations where only the side effect matters,
|
||||
// such as logging, printing, or updating external state.
|
||||
//
|
||||
// This is a fundamental concept in functional programming for handling side effects in a
|
||||
// controlled manner. Consumers can be composed, chained, or used in higher-order functions
|
||||
// to build complex side-effecting behaviors.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of value consumed by the function
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // A simple consumer that prints values
|
||||
// var printInt Consumer[int] = func(x int) {
|
||||
// fmt.Println(x)
|
||||
// }
|
||||
// printInt(42) // Prints: 42
|
||||
//
|
||||
// // A consumer that logs messages
|
||||
// var logger Consumer[string] = func(msg string) {
|
||||
// log.Println(msg)
|
||||
// }
|
||||
// logger("Hello, World!") // Logs: Hello, World!
|
||||
//
|
||||
// // Consumers can be used in functional pipelines
|
||||
// var saveToDatabase Consumer[User] = func(user User) {
|
||||
// db.Save(user)
|
||||
// }
|
||||
Consumer[A any] = func(A)
|
||||
|
||||
// Operator represents a function that transforms a Consumer[A] into a Consumer[B].
|
||||
// This is useful for composing and adapting consumers to work with different types.
|
||||
Operator[A, B any] = func(Consumer[A]) Consumer[B]
|
||||
)
|
||||
@@ -21,7 +21,34 @@ import (
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
)
|
||||
|
||||
// withContext wraps an existing IOEither and performs a context check for cancellation before delegating
|
||||
// WithContext wraps an IOResult and performs a context check for cancellation before executing.
|
||||
// This ensures that if the context is already cancelled, the computation short-circuits immediately
|
||||
// without executing the wrapped computation.
|
||||
//
|
||||
// This is useful for adding cancellation awareness to computations that might not check the context themselves.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of the success value
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context to check for cancellation
|
||||
// - ma: The IOResult to wrap with context checking
|
||||
//
|
||||
// Returns:
|
||||
// - An IOResult that checks for cancellation before executing
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// computation := func() Result[string] {
|
||||
// // Long-running operation
|
||||
// return result.Of("done")
|
||||
// }
|
||||
//
|
||||
// ctx, cancel := context.WithCancel(context.Background())
|
||||
// cancel() // Cancel immediately
|
||||
//
|
||||
// wrapped := WithContext(ctx, computation)
|
||||
// result := wrapped() // Returns Left with context.Canceled error
|
||||
func WithContext[A any](ctx context.Context, ma IOResult[A]) IOResult[A] {
|
||||
return func() Result[A] {
|
||||
if ctx.Err() != nil {
|
||||
|
||||
@@ -6,6 +6,11 @@ import (
|
||||
)
|
||||
|
||||
type (
|
||||
// IOResult represents a synchronous computation that may fail with an error.
|
||||
// It's an alias for ioresult.IOResult[T].
|
||||
IOResult[T any] = ioresult.IOResult[T]
|
||||
Result[T any] = result.Result[T]
|
||||
|
||||
// Result represents a computation that may fail with an error.
|
||||
// It's an alias for result.Result[T].
|
||||
Result[T any] = result.Result[T]
|
||||
)
|
||||
|
||||
@@ -4,6 +4,65 @@ import (
|
||||
RIO "github.com/IBM/fp-go/v2/readerio"
|
||||
)
|
||||
|
||||
// Bracket ensures that a resource is properly acquired, used, and released, even if an error occurs.
|
||||
// This implements the bracket pattern for safe resource management with [ReaderIO].
|
||||
//
|
||||
// The bracket pattern guarantees that:
|
||||
// - The acquire action is executed first to obtain the resource
|
||||
// - The use function is called with the acquired resource
|
||||
// - The release function is always called with the resource and result, regardless of success or failure
|
||||
// - The final result from the use function is returned
|
||||
//
|
||||
// This is particularly useful for managing resources like file handles, database connections,
|
||||
// or locks that must be cleaned up properly.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of the acquired resource
|
||||
// - B: The type of the result produced by the use function
|
||||
// - ANY: The type returned by the release function (typically ignored)
|
||||
//
|
||||
// Parameters:
|
||||
// - acquire: A ReaderIO that acquires the resource
|
||||
// - use: A Kleisli arrow that uses the resource and produces a result
|
||||
// - release: A function that releases the resource, receiving both the resource and the result
|
||||
//
|
||||
// Returns:
|
||||
// - A ReaderIO[B] that safely manages the resource lifecycle
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Acquire a file handle
|
||||
// acquireFile := func(ctx context.Context) IO[*os.File] {
|
||||
// return func() *os.File {
|
||||
// f, _ := os.Open("data.txt")
|
||||
// return f
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Use the file
|
||||
// readFile := func(f *os.File) ReaderIO[string] {
|
||||
// return func(ctx context.Context) IO[string] {
|
||||
// return func() string {
|
||||
// data, _ := io.ReadAll(f)
|
||||
// return string(data)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Release the file
|
||||
// closeFile := func(f *os.File, result string) ReaderIO[any] {
|
||||
// return func(ctx context.Context) IO[any] {
|
||||
// return func() any {
|
||||
// f.Close()
|
||||
// return nil
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Safely read file with automatic cleanup
|
||||
// safeRead := Bracket(acquireFile, readFile, closeFile)
|
||||
// result := safeRead(context.Background())()
|
||||
//
|
||||
//go:inline
|
||||
func Bracket[
|
||||
A, B, ANY any](
|
||||
|
||||
65
v2/context/readerio/consumer.go
Normal file
65
v2/context/readerio/consumer.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package readerio
|
||||
|
||||
import "github.com/IBM/fp-go/v2/io"
|
||||
|
||||
// ChainConsumer chains a consumer function into a ReaderIO computation, discarding the original value.
|
||||
// This is useful for performing side effects (like logging or metrics) that consume a value
|
||||
// but don't produce a meaningful result.
|
||||
//
|
||||
// The consumer is executed for its side effects, and the computation returns an empty struct.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of value to consume
|
||||
//
|
||||
// Parameters:
|
||||
// - c: A consumer function that performs side effects on the value
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that chains the consumer and returns struct{}
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// logUser := func(u User) {
|
||||
// log.Printf("Processing user: %s", u.Name)
|
||||
// }
|
||||
//
|
||||
// pipeline := F.Pipe2(
|
||||
// fetchUser(123),
|
||||
// ChainConsumer(logUser),
|
||||
// )
|
||||
//
|
||||
//go:inline
|
||||
func ChainConsumer[A any](c Consumer[A]) Operator[A, Void] {
|
||||
return ChainIOK(io.FromConsumer(c))
|
||||
}
|
||||
|
||||
// ChainFirstConsumer chains a consumer function into a ReaderIO computation, preserving the original value.
|
||||
// This is useful for performing side effects (like logging or metrics) while passing the value through unchanged.
|
||||
//
|
||||
// The consumer is executed for its side effects, but the original value is returned.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of value to consume and return
|
||||
//
|
||||
// Parameters:
|
||||
// - c: A consumer function that performs side effects on the value
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that chains the consumer and returns the original value
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// logUser := func(u User) {
|
||||
// log.Printf("User: %s", u.Name)
|
||||
// }
|
||||
//
|
||||
// pipeline := F.Pipe3(
|
||||
// fetchUser(123),
|
||||
// ChainFirstConsumer(logUser), // Logs but passes user through
|
||||
// Map(func(u User) string { return u.Email }),
|
||||
// )
|
||||
//
|
||||
//go:inline
|
||||
func ChainFirstConsumer[A any](c Consumer[A]) Operator[A, A] {
|
||||
return ChainFirstIOK(io.FromConsumer(c))
|
||||
}
|
||||
@@ -7,11 +7,108 @@ import (
|
||||
RIO "github.com/IBM/fp-go/v2/readerio"
|
||||
)
|
||||
|
||||
// SequenceReader transforms a ReaderIO containing a Reader into a Reader containing a ReaderIO.
|
||||
// This "flips" the nested structure, allowing you to provide the Reader's environment first,
|
||||
// then get a ReaderIO that can be executed with a context.
|
||||
//
|
||||
// Type transformation:
|
||||
//
|
||||
// From: ReaderIO[Reader[R, A]]
|
||||
// = func(context.Context) func() func(R) A
|
||||
//
|
||||
// To: Reader[R, ReaderIO[A]]
|
||||
// = func(R) func(context.Context) func() A
|
||||
//
|
||||
// This is useful for point-free style programming where you want to partially apply
|
||||
// the Reader's environment before dealing with the context.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - R: The environment type that the Reader depends on
|
||||
// - A: The value type
|
||||
//
|
||||
// Parameters:
|
||||
// - ma: A ReaderIO containing a Reader
|
||||
//
|
||||
// Returns:
|
||||
// - A Reader that produces a ReaderIO when given an environment
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type Config struct {
|
||||
// Timeout int
|
||||
// }
|
||||
//
|
||||
// // A computation that produces a Reader
|
||||
// getMultiplier := func(ctx context.Context) IO[func(Config) int] {
|
||||
// return func() func(Config) int {
|
||||
// return func(cfg Config) int {
|
||||
// return cfg.Timeout * 2
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Sequence it to apply Config first
|
||||
// sequenced := SequenceReader[Config, int](getMultiplier)
|
||||
// cfg := Config{Timeout: 30}
|
||||
// result := sequenced(cfg)(context.Background())() // Returns 60
|
||||
//
|
||||
//go:inline
|
||||
func SequenceReader[R, A any](ma ReaderIO[Reader[R, A]]) Reader[R, ReaderIO[A]] {
|
||||
return RIO.SequenceReader(ma)
|
||||
}
|
||||
|
||||
// TraverseReader applies a Reader-based transformation to a ReaderIO, introducing a new environment dependency.
|
||||
//
|
||||
// This function takes a Reader-based Kleisli arrow and returns a function that can transform
|
||||
// a ReaderIO. The result allows you to provide the Reader's environment (R) first, which then
|
||||
// produces a ReaderIO that depends on the context.
|
||||
//
|
||||
// Type transformation:
|
||||
//
|
||||
// From: ReaderIO[A]
|
||||
// = func(context.Context) func() A
|
||||
//
|
||||
// With: reader.Kleisli[R, A, B]
|
||||
// = func(A) func(R) B
|
||||
//
|
||||
// To: func(ReaderIO[A]) func(R) ReaderIO[B]
|
||||
// = func(ReaderIO[A]) func(R) func(context.Context) func() B
|
||||
//
|
||||
// This enables transforming values within a ReaderIO using environment-dependent logic.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - R: The environment type that the Reader depends on
|
||||
// - A: The input value type
|
||||
// - B: The output value type
|
||||
//
|
||||
// Parameters:
|
||||
// - f: A Reader-based Kleisli arrow that transforms A to B using environment R
|
||||
//
|
||||
// Returns:
|
||||
// - A function that takes a ReaderIO[A] and returns a function from R to ReaderIO[B]
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type Config struct {
|
||||
// Multiplier int
|
||||
// }
|
||||
//
|
||||
// // A Reader-based transformation
|
||||
// multiply := func(x int) func(Config) int {
|
||||
// return func(cfg Config) int {
|
||||
// return x * cfg.Multiplier
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Apply TraverseReader
|
||||
// traversed := TraverseReader[Config, int, int](multiply)
|
||||
// computation := Of(10)
|
||||
// result := traversed(computation)
|
||||
//
|
||||
// // Provide Config to get final result
|
||||
// cfg := Config{Multiplier: 5}
|
||||
// finalResult := result(cfg)(context.Background())() // Returns 50
|
||||
//
|
||||
//go:inline
|
||||
func TraverseReader[R, A, B any](
|
||||
f reader.Kleisli[R, A, B],
|
||||
|
||||
@@ -7,6 +7,40 @@ import (
|
||||
"github.com/IBM/fp-go/v2/logging"
|
||||
)
|
||||
|
||||
// SLogWithCallback creates a Kleisli arrow that logs a value with a custom logger and log level.
|
||||
// The value is logged and then passed through unchanged, making this useful for debugging
|
||||
// and monitoring values as they flow through a ReaderIO computation.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of value to log and pass through
|
||||
//
|
||||
// Parameters:
|
||||
// - logLevel: The slog.Level to use for logging (e.g., slog.LevelInfo, slog.LevelDebug)
|
||||
// - cb: Callback function to retrieve the *slog.Logger from the context
|
||||
// - message: A descriptive message to include in the log entry
|
||||
//
|
||||
// Returns:
|
||||
// - A Kleisli arrow that logs the value and returns it unchanged
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// getMyLogger := func(ctx context.Context) *slog.Logger {
|
||||
// if logger := ctx.Value("logger"); logger != nil {
|
||||
// return logger.(*slog.Logger)
|
||||
// }
|
||||
// return slog.Default()
|
||||
// }
|
||||
//
|
||||
// debugLog := SLogWithCallback[User](
|
||||
// slog.LevelDebug,
|
||||
// getMyLogger,
|
||||
// "Processing user",
|
||||
// )
|
||||
//
|
||||
// pipeline := F.Pipe2(
|
||||
// fetchUser(123),
|
||||
// Chain(debugLog),
|
||||
// )
|
||||
func SLogWithCallback[A any](
|
||||
logLevel slog.Level,
|
||||
cb func(context.Context) *slog.Logger,
|
||||
@@ -23,6 +57,34 @@ func SLogWithCallback[A any](
|
||||
}
|
||||
}
|
||||
|
||||
// SLog creates a Kleisli arrow that logs a value at Info level and passes it through unchanged.
|
||||
// This is a convenience wrapper around SLogWithCallback with standard settings.
|
||||
//
|
||||
// The value is logged with the provided message and then returned unchanged, making this
|
||||
// useful for debugging and monitoring values in a ReaderIO computation pipeline.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of value to log and pass through
|
||||
//
|
||||
// Parameters:
|
||||
// - message: A descriptive message to include in the log entry
|
||||
//
|
||||
// Returns:
|
||||
// - A Kleisli arrow that logs the value at Info level and returns it unchanged
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// pipeline := F.Pipe3(
|
||||
// fetchUser(123),
|
||||
// Chain(SLog[User]("Fetched user")),
|
||||
// Map(func(u User) string { return u.Name }),
|
||||
// Chain(SLog[string]("Extracted name")),
|
||||
// )
|
||||
//
|
||||
// result := pipeline(context.Background())()
|
||||
// // Logs: "Fetched user" value={ID:123 Name:"Alice"}
|
||||
// // Logs: "Extracted name" value="Alice"
|
||||
//
|
||||
//go:inline
|
||||
func SLog[A any](message string) Kleisli[A, A] {
|
||||
return SLogWithCallback[A](slog.LevelInfo, logging.GetLoggerFromContext, message)
|
||||
|
||||
@@ -753,3 +753,17 @@ func WithDeadline[A any](deadline time.Time) Operator[A, A] {
|
||||
return context.WithDeadline(ctx, deadline)
|
||||
})
|
||||
}
|
||||
|
||||
// Delay creates an operation that passes in the value after some delay
|
||||
//
|
||||
//go:inline
|
||||
func Delay[A any](delay time.Duration) Operator[A, A] {
|
||||
return RIO.Delay[context.Context, A](delay)
|
||||
}
|
||||
|
||||
// After creates an operation that passes after the given [time.Time]
|
||||
//
|
||||
//go:inline
|
||||
func After[R, E, A any](timestamp time.Time) Operator[A, A] {
|
||||
return RIO.After[context.Context, A](timestamp)
|
||||
}
|
||||
|
||||
86
v2/context/readerio/rec.go
Normal file
86
v2/context/readerio/rec.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package readerio
|
||||
|
||||
import (
|
||||
"github.com/IBM/fp-go/v2/readerio"
|
||||
)
|
||||
|
||||
// TailRec implements stack-safe tail recursion for the ReaderIO monad.
|
||||
//
|
||||
// This function enables recursive computations that depend on a [context.Context] and
|
||||
// perform side effects, without risking stack overflow. It uses an iterative loop to
|
||||
// execute the recursion, making it safe for deep or unbounded recursion.
|
||||
//
|
||||
// The function takes a Kleisli arrow that returns Trampoline[A, B]:
|
||||
// - Bounce(A): Continue recursion with the new state A
|
||||
// - Land(B): Terminate recursion and return the final result B
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The state type that changes during recursion
|
||||
// - B: The final result type when recursion terminates
|
||||
//
|
||||
// Parameters:
|
||||
// - f: A Kleisli arrow (A => ReaderIO[Trampoline[A, B]]) that controls recursion flow
|
||||
//
|
||||
// Returns:
|
||||
// - A Kleisli arrow (A => ReaderIO[B]) that executes the recursion safely
|
||||
//
|
||||
// Example - Countdown:
|
||||
//
|
||||
// countdownStep := func(n int) ReaderIO[tailrec.Trampoline[int, string]] {
|
||||
// return func(ctx context.Context) IO[tailrec.Trampoline[int, string]] {
|
||||
// return func() tailrec.Trampoline[int, string] {
|
||||
// if n <= 0 {
|
||||
// return tailrec.Land[int]("Done!")
|
||||
// }
|
||||
// return tailrec.Bounce[string](n - 1)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// countdown := TailRec(countdownStep)
|
||||
// result := countdown(10)(context.Background())() // Returns "Done!"
|
||||
//
|
||||
// Example - Sum with context:
|
||||
//
|
||||
// type SumState struct {
|
||||
// numbers []int
|
||||
// total int
|
||||
// }
|
||||
//
|
||||
// sumStep := func(state SumState) ReaderIO[tailrec.Trampoline[SumState, int]] {
|
||||
// return func(ctx context.Context) IO[tailrec.Trampoline[SumState, int]] {
|
||||
// return func() tailrec.Trampoline[SumState, int] {
|
||||
// if len(state.numbers) == 0 {
|
||||
// return tailrec.Land[SumState](state.total)
|
||||
// }
|
||||
// return tailrec.Bounce[int](SumState{
|
||||
// numbers: state.numbers[1:],
|
||||
// total: state.total + state.numbers[0],
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// sum := TailRec(sumStep)
|
||||
// result := sum(SumState{numbers: []int{1, 2, 3, 4, 5}})(context.Background())()
|
||||
// // Returns 15, safe even for very large slices
|
||||
//
|
||||
//go:inline
|
||||
func TailRec[A, B any](f Kleisli[A, Trampoline[A, B]]) Kleisli[A, B] {
|
||||
return readerio.TailRec(f)
|
||||
}
|
||||
106
v2/context/readerio/retry.go
Normal file
106
v2/context/readerio/retry.go
Normal file
@@ -0,0 +1,106 @@
|
||||
// Copyright (c) 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package readerio
|
||||
|
||||
import (
|
||||
"github.com/IBM/fp-go/v2/retry"
|
||||
RG "github.com/IBM/fp-go/v2/retry/generic"
|
||||
)
|
||||
|
||||
// Retrying retries a ReaderIO computation according to a retry policy.
|
||||
//
|
||||
// This function implements a retry mechanism for operations that depend on a [context.Context]
|
||||
// and perform side effects (IO). The retry loop continues until one of the following occurs:
|
||||
// - The action succeeds and the check function returns false (no retry needed)
|
||||
// - The retry policy returns None (retry limit reached)
|
||||
// - The check function returns false (indicating success or a non-retryable condition)
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of the value produced by the action
|
||||
//
|
||||
// Parameters:
|
||||
//
|
||||
// - policy: A RetryPolicy that determines when and how long to wait between retries.
|
||||
// The policy receives a RetryStatus on each iteration and returns an optional delay.
|
||||
// If it returns None, retrying stops. Common policies include LimitRetries,
|
||||
// ExponentialBackoff, and CapDelay from the retry package.
|
||||
//
|
||||
// - action: A Kleisli arrow that takes a RetryStatus and returns a ReaderIO[A].
|
||||
// This function is called on each retry attempt and receives information about the
|
||||
// current retry state (iteration number, cumulative delay, etc.).
|
||||
//
|
||||
// - check: A predicate function that examines the result A and returns true if the
|
||||
// operation should be retried, or false if it should stop. This allows you to
|
||||
// distinguish between retryable conditions and successful/permanent results.
|
||||
//
|
||||
// Returns:
|
||||
// - A ReaderIO[A] that, when executed with a context, will perform the retry logic
|
||||
// and return the final result.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Create a retry policy: exponential backoff with a cap, limited to 5 retries
|
||||
// policy := M.Concat(
|
||||
// retry.LimitRetries(5),
|
||||
// retry.CapDelay(10*time.Second, retry.ExponentialBackoff(100*time.Millisecond)),
|
||||
// )(retry.Monoid)
|
||||
//
|
||||
// // Action that fetches data, with retry status information
|
||||
// fetchData := func(status retry.RetryStatus) ReaderIO[string] {
|
||||
// return func(ctx context.Context) IO[string] {
|
||||
// return func() string {
|
||||
// // Simulate an operation that might fail
|
||||
// if status.IterNumber < 3 {
|
||||
// return "" // Empty result indicates failure
|
||||
// }
|
||||
// return "success"
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Check function: retry if result is empty
|
||||
// shouldRetry := func(s string) bool {
|
||||
// return s == ""
|
||||
// }
|
||||
//
|
||||
// // Create the retrying computation
|
||||
// retryingFetch := Retrying(policy, fetchData, shouldRetry)
|
||||
//
|
||||
// // Execute
|
||||
// ctx := context.Background()
|
||||
// result := retryingFetch(ctx)() // Returns "success" after 3 attempts
|
||||
//
|
||||
//go:inline
|
||||
func Retrying[A any](
|
||||
policy retry.RetryPolicy,
|
||||
action Kleisli[retry.RetryStatus, A],
|
||||
check Predicate[A],
|
||||
) ReaderIO[A] {
|
||||
// get an implementation for the types
|
||||
return RG.Retrying(
|
||||
Chain[A, Trampoline[retry.RetryStatus, A]],
|
||||
Map[retry.RetryStatus, Trampoline[retry.RetryStatus, A]],
|
||||
Of[Trampoline[retry.RetryStatus, A]],
|
||||
Of[retry.RetryStatus],
|
||||
Delay[retry.RetryStatus],
|
||||
|
||||
TailRec,
|
||||
|
||||
policy,
|
||||
action,
|
||||
check,
|
||||
)
|
||||
}
|
||||
@@ -18,10 +18,15 @@ package readerio
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/IBM/fp-go/v2/consumer"
|
||||
"github.com/IBM/fp-go/v2/either"
|
||||
"github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
"github.com/IBM/fp-go/v2/lazy"
|
||||
"github.com/IBM/fp-go/v2/predicate"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
"github.com/IBM/fp-go/v2/readerio"
|
||||
"github.com/IBM/fp-go/v2/tailrec"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -66,4 +71,14 @@ type (
|
||||
//
|
||||
// Operator[A, B] is equivalent to func(ReaderIO[A]) func(context.Context) func() B
|
||||
Operator[A, B any] = Kleisli[ReaderIO[A], B]
|
||||
|
||||
Consumer[A any] = consumer.Consumer[A]
|
||||
|
||||
Either[E, A any] = either.Either[E, A]
|
||||
|
||||
Trampoline[B, L any] = tailrec.Trampoline[B, L]
|
||||
|
||||
Predicate[A any] = predicate.Predicate[A]
|
||||
|
||||
Void = function.Void
|
||||
)
|
||||
|
||||
@@ -8,9 +8,10 @@ This document explains how the `Sequence*` functions in the `context/readeriores
|
||||
2. [The Problem: Nested Function Application](#the-problem-nested-function-application)
|
||||
3. [The Solution: Sequence Functions](#the-solution-sequence-functions)
|
||||
4. [How Sequence Enables Point-Free Style](#how-sequence-enables-point-free-style)
|
||||
5. [Practical Benefits](#practical-benefits)
|
||||
6. [Examples](#examples)
|
||||
7. [Comparison: With and Without Sequence](#comparison-with-and-without-sequence)
|
||||
5. [TraverseReader: Introducing Dependencies](#traversereader-introducing-dependencies)
|
||||
6. [Practical Benefits](#practical-benefits)
|
||||
7. [Examples](#examples)
|
||||
8. [Comparison: With and Without Sequence](#comparison-with-and-without-sequence)
|
||||
|
||||
## What is Point-Free Style?
|
||||
|
||||
@@ -25,10 +26,7 @@ func double(x int) int {
|
||||
|
||||
**Point-free style (without points):**
|
||||
```go
|
||||
var double = F.Flow2(
|
||||
N.Mul(2),
|
||||
identity,
|
||||
)
|
||||
var double = N.Mul(2)
|
||||
```
|
||||
|
||||
The key benefit is that point-free style emphasizes **what** the function does (its transformation) rather than **how** it manipulates data.
|
||||
@@ -99,7 +97,7 @@ The `Sequence*` functions solve this by "flipping" or "sequencing" the nested st
|
||||
```go
|
||||
func SequenceReader[R, A any](
|
||||
ma ReaderIOResult[Reader[R, A]]
|
||||
) reader.Kleisli[context.Context, R, IOResult[A]]
|
||||
) Kleisli[R, A]
|
||||
```
|
||||
|
||||
**Type transformation:**
|
||||
@@ -115,7 +113,7 @@ Now `R` (the Reader's environment) comes **first**, before `context.Context`!
|
||||
```go
|
||||
func SequenceReaderIO[R, A any](
|
||||
ma ReaderIOResult[ReaderIO[R, A]]
|
||||
) reader.Kleisli[context.Context, R, IOResult[A]]
|
||||
) Kleisli[R, A]
|
||||
```
|
||||
|
||||
**Type transformation:**
|
||||
@@ -129,7 +127,7 @@ To: func(R) func(context.Context) func() Either[error, A]
|
||||
```go
|
||||
func SequenceReaderResult[R, A any](
|
||||
ma ReaderIOResult[ReaderResult[R, A]]
|
||||
) reader.Kleisli[context.Context, R, IOResult[A]]
|
||||
) Kleisli[R, A]
|
||||
```
|
||||
|
||||
**Type transformation:**
|
||||
@@ -222,9 +220,307 @@ authInfo := authService(ctx)()
|
||||
userInfo := userService(ctx)()
|
||||
```
|
||||
|
||||
## TraverseReader: Introducing Dependencies
|
||||
|
||||
While `SequenceReader` flips the parameter order of an existing nested structure, `TraverseReader` allows you to **introduce** a new Reader dependency into an existing computation.
|
||||
|
||||
### Function Signature
|
||||
|
||||
```go
|
||||
func TraverseReader[R, A, B any](
|
||||
f reader.Kleisli[R, A, B],
|
||||
) func(ReaderIOResult[A]) Kleisli[R, B]
|
||||
```
|
||||
|
||||
**Type transformation:**
|
||||
```
|
||||
Input: ReaderIOResult[A] = func(context.Context) func() Either[error, A]
|
||||
With: reader.Kleisli[R, A, B] = func(A) func(R) B
|
||||
Output: Kleisli[R, B] = func(R) func(context.Context) func() Either[error, B]
|
||||
```
|
||||
|
||||
### What It Does
|
||||
|
||||
`TraverseReader` takes:
|
||||
1. A Reader-based transformation `f: func(A) func(R) B` that depends on environment `R`
|
||||
2. Returns a function that transforms `ReaderIOResult[A]` into `Kleisli[R, B]`
|
||||
|
||||
This allows you to:
|
||||
- Add environment dependencies to computations that don't have them yet
|
||||
- Transform values within a ReaderIOResult using environment-dependent logic
|
||||
- Build composable pipelines where transformations depend on configuration
|
||||
|
||||
### Key Difference from SequenceReader
|
||||
|
||||
- **SequenceReader**: Works with computations that **already contain** a Reader (`ReaderIOResult[Reader[R, A]]`)
|
||||
- Flips the order so `R` comes first
|
||||
- No transformation of the value itself
|
||||
|
||||
- **TraverseReader**: Works with computations that **don't have** a Reader yet (`ReaderIOResult[A]`)
|
||||
- Introduces a new Reader dependency via a transformation function
|
||||
- Transforms `A` to `B` using environment `R`
|
||||
|
||||
### Example: Adding Configuration to a Computation
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Multiplier int
|
||||
Prefix string
|
||||
}
|
||||
|
||||
// Original computation that just produces an int
|
||||
getValue := func(ctx context.Context) func() Either[error, int] {
|
||||
return func() Either[error, int] {
|
||||
return Right[error](10)
|
||||
}
|
||||
}
|
||||
|
||||
// A Reader-based transformation that depends on Config
|
||||
formatWithConfig := func(n int) func(Config) string {
|
||||
return func(cfg Config) string {
|
||||
result := n * cfg.Multiplier
|
||||
return fmt.Sprintf("%s: %d", cfg.Prefix, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Use TraverseReader to introduce Config dependency
|
||||
traversed := TraverseReader[Config, int, string](formatWithConfig)
|
||||
withConfig := traversed(getValue)
|
||||
|
||||
// Now we can provide Config to get the final result
|
||||
cfg := Config{Multiplier: 5, Prefix: "Result"}
|
||||
ctx := context.Background()
|
||||
result := withConfig(cfg)(ctx)() // Returns Right("Result: 50")
|
||||
```
|
||||
|
||||
### Point-Free Composition with TraverseReader
|
||||
|
||||
```go
|
||||
// Build a pipeline that introduces dependencies at each stage
|
||||
var pipeline = F.Flow4(
|
||||
loadValue, // ReaderIOResult[int]
|
||||
TraverseReader(multiplyByConfig), // Kleisli[Config, int]
|
||||
applyConfig(cfg), // ReaderIOResult[int]
|
||||
Chain(TraverseReader(formatWithStyle)), // Introduce another dependency
|
||||
)
|
||||
```
|
||||
|
||||
### When to Use TraverseReader vs SequenceReader
|
||||
|
||||
**Use SequenceReader when:**
|
||||
- Your computation already returns a Reader: `ReaderIOResult[Reader[R, A]]`
|
||||
- You just want to flip the parameter order
|
||||
- No transformation of the value is needed
|
||||
|
||||
```go
|
||||
// Already have Reader[Config, int]
|
||||
computation := getComputation() // ReaderIOResult[Reader[Config, int]]
|
||||
sequenced := SequenceReader[Config, int](computation)
|
||||
result := sequenced(cfg)(ctx)()
|
||||
```
|
||||
|
||||
**Use TraverseReader when:**
|
||||
- Your computation doesn't have a Reader yet: `ReaderIOResult[A]`
|
||||
- You want to transform the value using environment-dependent logic
|
||||
- You're introducing a new dependency into the pipeline
|
||||
|
||||
```go
|
||||
// Have ReaderIOResult[int], want to add Config dependency
|
||||
computation := getValue() // ReaderIOResult[int]
|
||||
traversed := TraverseReader[Config, int, string](formatWithConfig)
|
||||
withDep := traversed(computation)
|
||||
result := withDep(cfg)(ctx)()
|
||||
```
|
||||
|
||||
### Practical Example: Multi-Stage Processing
|
||||
|
||||
```go
|
||||
type DatabaseConfig struct {
|
||||
ConnectionString string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type FormattingConfig struct {
|
||||
DateFormat string
|
||||
Timezone string
|
||||
}
|
||||
|
||||
// Stage 1: Load raw data (no dependencies yet)
|
||||
loadData := func(ctx context.Context) func() Either[error, RawData] {
|
||||
// ... implementation
|
||||
}
|
||||
|
||||
// Stage 2: Process with database config
|
||||
processWithDB := func(raw RawData) func(DatabaseConfig) ProcessedData {
|
||||
return func(cfg DatabaseConfig) ProcessedData {
|
||||
// Use cfg.ConnectionString, cfg.Timeout
|
||||
return ProcessedData{/* ... */}
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 3: Format with formatting config
|
||||
formatData := func(processed ProcessedData) func(FormattingConfig) string {
|
||||
return func(cfg FormattingConfig) string {
|
||||
// Use cfg.DateFormat, cfg.Timezone
|
||||
return "formatted result"
|
||||
}
|
||||
}
|
||||
|
||||
// Build pipeline introducing dependencies at each stage
|
||||
var pipeline = F.Flow3(
|
||||
loadData,
|
||||
TraverseReader[DatabaseConfig, RawData, ProcessedData](processWithDB),
|
||||
// Now we have Kleisli[DatabaseConfig, ProcessedData]
|
||||
applyConfig(dbConfig),
|
||||
// Now we have ReaderIOResult[ProcessedData]
|
||||
TraverseReader[FormattingConfig, ProcessedData, string](formatData),
|
||||
// Now we have Kleisli[FormattingConfig, string]
|
||||
)
|
||||
|
||||
// Execute with both configs
|
||||
result := pipeline(fmtConfig)(ctx)()
|
||||
```
|
||||
|
||||
### Combining TraverseReader and SequenceReader
|
||||
|
||||
You can combine both functions in complex pipelines:
|
||||
|
||||
```go
|
||||
// Start with nested Reader
|
||||
computation := getComputation() // ReaderIOResult[Reader[Config, User]]
|
||||
|
||||
var pipeline = F.Flow4(
|
||||
computation,
|
||||
SequenceReader[Config, User], // Flip to get Kleisli[Config, User]
|
||||
applyConfig(cfg), // Apply config, get ReaderIOResult[User]
|
||||
TraverseReader(enrichWithDatabase), // Add database dependency
|
||||
// Now have Kleisli[Database, EnrichedUser]
|
||||
)
|
||||
|
||||
result := pipeline(db)(ctx)()
|
||||
```
|
||||
|
||||
## Practical Benefits
|
||||
|
||||
### 1. **Improved Testability**
|
||||
### 1. **Performance: Eager Construction, Lazy Execution**
|
||||
|
||||
One of the most important but often overlooked benefits of point-free style is its performance characteristic: **the program structure is constructed eagerly (at definition time), but execution happens lazily (at runtime)**.
|
||||
|
||||
#### Construction Happens Once
|
||||
|
||||
When you define a pipeline using point-free style with `F.Flow`, `F.Pipe`, or function composition, the composition structure is built immediately at definition time:
|
||||
|
||||
```go
|
||||
// Point-free style - composition built ONCE at definition time
|
||||
var processUser = F.Flow3(
|
||||
getDatabase,
|
||||
SequenceReader[DatabaseConfig, Database],
|
||||
applyConfig(dbConfig),
|
||||
)
|
||||
// The pipeline structure is now fixed in memory
|
||||
```
|
||||
|
||||
#### Execution Happens on Demand
|
||||
|
||||
The actual computation only runs when you provide the final parameters and invoke the result:
|
||||
|
||||
```go
|
||||
// Execute multiple times - only execution cost, no re-composition
|
||||
result1 := processUser(ctx1)() // Fast - reuses pre-built pipeline
|
||||
result2 := processUser(ctx2)() // Fast - reuses pre-built pipeline
|
||||
result3 := processUser(ctx3)() // Fast - reuses pre-built pipeline
|
||||
```
|
||||
|
||||
#### Performance Benefit for Repeated Execution
|
||||
|
||||
If a flow is executed multiple times, the point-free style is significantly more efficient because:
|
||||
|
||||
1. **Composition overhead is paid once** - The function composition happens at definition time
|
||||
2. **No re-interpretation** - Each execution doesn't need to rebuild the pipeline
|
||||
3. **Memory efficiency** - The composed function is created once and reused
|
||||
4. **Better for hot paths** - Ideal for high-frequency operations
|
||||
|
||||
#### Comparison: Point-Free vs. Imperative
|
||||
|
||||
```go
|
||||
// Imperative style - reconstruction on EVERY call
|
||||
func processUserImperative(ctx context.Context) Either[error, Database] {
|
||||
// This function body is re-interpreted/executed every time
|
||||
dbComp := getDatabase()(ctx)()
|
||||
if dbReader, err := either.Unwrap(dbComp); err != nil {
|
||||
return Left[Database](err)
|
||||
}
|
||||
db := dbReader(dbConfig)
|
||||
// ... manual composition happens on every invocation
|
||||
return Right[error](db)
|
||||
}
|
||||
|
||||
// Point-free style - composition built ONCE
|
||||
var processUserPointFree = F.Flow3(
|
||||
getDatabase,
|
||||
SequenceReader[DatabaseConfig, Database],
|
||||
applyConfig(dbConfig),
|
||||
)
|
||||
|
||||
// Benchmark scenario: 1000 executions
|
||||
for i := 0; i < 1000; i++ {
|
||||
// Imperative: pays composition cost 1000 times
|
||||
result := processUserImperative(ctx)()
|
||||
|
||||
// Point-free: pays composition cost once, execution cost 1000 times
|
||||
result := processUserPointFree(ctx)()
|
||||
}
|
||||
```
|
||||
|
||||
#### When This Matters Most
|
||||
|
||||
The performance benefit of eager construction is particularly important for:
|
||||
|
||||
- **High-frequency operations** - APIs, event handlers, request processors
|
||||
- **Batch processing** - Same pipeline processes many items
|
||||
- **Long-running services** - Pipelines defined once at startup, executed millions of times
|
||||
- **Hot code paths** - Performance-critical sections that run repeatedly
|
||||
- **Stream processing** - Processing continuous data streams
|
||||
|
||||
#### Example: API Handler
|
||||
|
||||
```go
|
||||
// Define pipeline once at application startup
|
||||
var handleUserRequest = F.Flow4(
|
||||
parseRequest,
|
||||
SequenceReader[Database, UserRequest],
|
||||
applyDatabase(db),
|
||||
Chain(validateAndProcess),
|
||||
)
|
||||
|
||||
// Execute thousands of times per second
|
||||
func apiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// No composition overhead - just execution
|
||||
result := handleUserRequest(r.Context())()
|
||||
// ... handle result
|
||||
}
|
||||
```
|
||||
|
||||
#### Memory and CPU Efficiency
|
||||
|
||||
```go
|
||||
// Point-free: O(1) composition overhead
|
||||
var pipeline = F.Flow5(step1, step2, step3, step4, step5)
|
||||
// Composed once, stored in memory
|
||||
|
||||
// Execute N times: O(N) execution cost only
|
||||
for i := 0; i < N; i++ {
|
||||
result := pipeline(input[i])
|
||||
}
|
||||
|
||||
// Imperative: O(N) composition + execution cost
|
||||
for i := 0; i < N; i++ {
|
||||
// Composition logic runs every iteration
|
||||
result := step5(step4(step3(step2(step1(input[i])))))
|
||||
}
|
||||
```
|
||||
|
||||
### 2. **Improved Testability**
|
||||
|
||||
Inject test dependencies easily:
|
||||
|
||||
@@ -240,7 +536,7 @@ testQuery := queryWithDB(testDB)
|
||||
// Same computation, different dependencies
|
||||
```
|
||||
|
||||
### 2. **Better Separation of Concerns**
|
||||
### 3. **Better Separation of Concerns**
|
||||
|
||||
Separate configuration from execution:
|
||||
|
||||
@@ -253,7 +549,7 @@ computation := sequenced(cfg)
|
||||
result := computation(ctx)()
|
||||
```
|
||||
|
||||
### 3. **Enhanced Composability**
|
||||
### 4. **Enhanced Composability**
|
||||
|
||||
Build complex pipelines from simple pieces:
|
||||
|
||||
@@ -266,7 +562,7 @@ var processUser = F.Flow4(
|
||||
)
|
||||
```
|
||||
|
||||
### 4. **Reduced Boilerplate**
|
||||
### 5. **Reduced Boilerplate**
|
||||
|
||||
No need to manually thread parameters:
|
||||
|
||||
@@ -473,6 +769,7 @@ var processUser = func(userID string) ReaderIOResult[ProcessedUser] {
|
||||
5. **Reusability** increases as computations can be specialized early
|
||||
6. **Testability** improves through easy dependency injection
|
||||
7. **Separation of concerns** is clearer (configuration vs. execution)
|
||||
8. **Performance benefit**: Eager construction (once) + lazy execution (many times) = efficiency for repeated operations
|
||||
|
||||
## When to Use Sequence
|
||||
|
||||
|
||||
@@ -18,14 +18,13 @@ package readerioresult
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/IBM/fp-go/v2/context/readerio"
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/internal/apply"
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
"github.com/IBM/fp-go/v2/ioeither"
|
||||
"github.com/IBM/fp-go/v2/ioresult"
|
||||
L "github.com/IBM/fp-go/v2/optics/lens"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
"github.com/IBM/fp-go/v2/readerio"
|
||||
RIOR "github.com/IBM/fp-go/v2/readerioresult"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
)
|
||||
@@ -96,7 +95,7 @@ func Bind[S1, S2, T any](
|
||||
setter func(T) func(S1) S2,
|
||||
f Kleisli[S1, T],
|
||||
) Operator[S1, S2] {
|
||||
return RIOR.Bind(setter, f)
|
||||
return RIOR.Bind(setter, WithContextK(f))
|
||||
}
|
||||
|
||||
// Let attaches the result of a computation to a context [S1] to produce a context [S2]
|
||||
@@ -128,6 +127,13 @@ func BindTo[S1, T any](
|
||||
return RIOR.BindTo[context.Context](setter)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func BindToP[S1, T any](
|
||||
setter Prism[S1, T],
|
||||
) Operator[T, S1] {
|
||||
return BindTo(setter.ReverseGet)
|
||||
}
|
||||
|
||||
// ApS attaches a value to a context [S1] to produce a context [S2] by considering
|
||||
// the context and the value concurrently (using Applicative rather than Monad).
|
||||
// This allows independent computations to be combined without one depending on the result of the other.
|
||||
@@ -214,7 +220,7 @@ func ApS[S1, S2, T any](
|
||||
//
|
||||
//go:inline
|
||||
func ApSL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
lens Lens[S, T],
|
||||
fa ReaderIOResult[T],
|
||||
) Operator[S, S] {
|
||||
return ApS(lens.Set, fa)
|
||||
@@ -253,10 +259,10 @@ func ApSL[S, T any](
|
||||
//
|
||||
//go:inline
|
||||
func BindL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
lens Lens[S, T],
|
||||
f Kleisli[T, T],
|
||||
) Operator[S, S] {
|
||||
return RIOR.BindL(lens, f)
|
||||
return RIOR.BindL(lens, WithContextK(f))
|
||||
}
|
||||
|
||||
// LetL is a variant of Let that uses a lens to focus on a specific part of the context.
|
||||
@@ -289,8 +295,8 @@ func BindL[S, T any](
|
||||
//
|
||||
//go:inline
|
||||
func LetL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
f func(T) T,
|
||||
lens Lens[S, T],
|
||||
f Endomorphism[T],
|
||||
) Operator[S, S] {
|
||||
return RIOR.LetL[context.Context](lens, f)
|
||||
}
|
||||
@@ -322,7 +328,7 @@ func LetL[S, T any](
|
||||
//
|
||||
//go:inline
|
||||
func LetToL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
lens Lens[S, T],
|
||||
b T,
|
||||
) Operator[S, S] {
|
||||
return RIOR.LetToL[context.Context](lens, b)
|
||||
@@ -398,7 +404,7 @@ func BindReaderK[S1, S2, T any](
|
||||
//go:inline
|
||||
func BindReaderIOK[S1, S2, T any](
|
||||
setter func(T) func(S1) S2,
|
||||
f readerio.Kleisli[context.Context, S1, T],
|
||||
f readerio.Kleisli[S1, T],
|
||||
) Operator[S1, S2] {
|
||||
return Bind(setter, F.Flow2(f, FromReaderIO[T]))
|
||||
}
|
||||
@@ -443,7 +449,7 @@ func BindResultK[S1, S2, T any](
|
||||
//
|
||||
//go:inline
|
||||
func BindIOEitherKL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
lens Lens[S, T],
|
||||
f ioresult.Kleisli[T, T],
|
||||
) Operator[S, S] {
|
||||
return BindL(lens, F.Flow2(f, FromIOEither[T]))
|
||||
@@ -458,7 +464,7 @@ func BindIOEitherKL[S, T any](
|
||||
//
|
||||
//go:inline
|
||||
func BindIOResultKL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
lens Lens[S, T],
|
||||
f ioresult.Kleisli[T, T],
|
||||
) Operator[S, S] {
|
||||
return BindL(lens, F.Flow2(f, FromIOEither[T]))
|
||||
@@ -474,7 +480,7 @@ func BindIOResultKL[S, T any](
|
||||
//
|
||||
//go:inline
|
||||
func BindIOKL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
lens Lens[S, T],
|
||||
f io.Kleisli[T, T],
|
||||
) Operator[S, S] {
|
||||
return BindL(lens, F.Flow2(f, FromIO[T]))
|
||||
@@ -490,7 +496,7 @@ func BindIOKL[S, T any](
|
||||
//
|
||||
//go:inline
|
||||
func BindReaderKL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
lens Lens[S, T],
|
||||
f reader.Kleisli[context.Context, T, T],
|
||||
) Operator[S, S] {
|
||||
return BindL(lens, F.Flow2(f, FromReader[T]))
|
||||
@@ -506,8 +512,8 @@ func BindReaderKL[S, T any](
|
||||
//
|
||||
//go:inline
|
||||
func BindReaderIOKL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
f readerio.Kleisli[context.Context, T, T],
|
||||
lens Lens[S, T],
|
||||
f readerio.Kleisli[T, T],
|
||||
) Operator[S, S] {
|
||||
return BindL(lens, F.Flow2(f, FromReaderIO[T]))
|
||||
}
|
||||
@@ -627,7 +633,7 @@ func ApResultS[S1, S2, T any](
|
||||
//
|
||||
//go:inline
|
||||
func ApIOEitherSL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
lens Lens[S, T],
|
||||
fa IOResult[T],
|
||||
) Operator[S, S] {
|
||||
return F.Bind2nd(F.Flow2[ReaderIOResult[S], ioresult.Operator[S, S]], ioresult.ApSL(lens, fa))
|
||||
@@ -642,7 +648,7 @@ func ApIOEitherSL[S, T any](
|
||||
//
|
||||
//go:inline
|
||||
func ApIOResultSL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
lens Lens[S, T],
|
||||
fa IOResult[T],
|
||||
) Operator[S, S] {
|
||||
return F.Bind2nd(F.Flow2[ReaderIOResult[S], ioresult.Operator[S, S]], ioresult.ApSL(lens, fa))
|
||||
@@ -657,7 +663,7 @@ func ApIOResultSL[S, T any](
|
||||
//
|
||||
//go:inline
|
||||
func ApIOSL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
lens Lens[S, T],
|
||||
fa IO[T],
|
||||
) Operator[S, S] {
|
||||
return ApSL(lens, FromIO(fa))
|
||||
@@ -672,7 +678,7 @@ func ApIOSL[S, T any](
|
||||
//
|
||||
//go:inline
|
||||
func ApReaderSL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
lens Lens[S, T],
|
||||
fa Reader[context.Context, T],
|
||||
) Operator[S, S] {
|
||||
return ApSL(lens, FromReader(fa))
|
||||
@@ -687,7 +693,7 @@ func ApReaderSL[S, T any](
|
||||
//
|
||||
//go:inline
|
||||
func ApReaderIOSL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
lens Lens[S, T],
|
||||
fa ReaderIO[T],
|
||||
) Operator[S, S] {
|
||||
return ApSL(lens, FromReaderIO(fa))
|
||||
@@ -702,7 +708,7 @@ func ApReaderIOSL[S, T any](
|
||||
//
|
||||
//go:inline
|
||||
func ApEitherSL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
lens Lens[S, T],
|
||||
fa Result[T],
|
||||
) Operator[S, S] {
|
||||
return ApSL(lens, FromEither(fa))
|
||||
@@ -717,7 +723,7 @@ func ApEitherSL[S, T any](
|
||||
//
|
||||
//go:inline
|
||||
func ApResultSL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
lens Lens[S, T],
|
||||
fa Result[T],
|
||||
) Operator[S, S] {
|
||||
return ApSL(lens, FromResult(fa))
|
||||
|
||||
@@ -203,9 +203,7 @@ func TestApS_EmptyState(t *testing.T) {
|
||||
result := res(t.Context())()
|
||||
assert.True(t, E.IsRight(result))
|
||||
emptyOpt := E.ToOption(result)
|
||||
assert.True(t, O.IsSome(emptyOpt))
|
||||
empty, _ := O.Unwrap(emptyOpt)
|
||||
assert.Equal(t, Empty{}, empty)
|
||||
assert.Equal(t, O.Of(Empty{}), emptyOpt)
|
||||
}
|
||||
|
||||
func TestApS_ChainedWithBind(t *testing.T) {
|
||||
|
||||
@@ -16,11 +16,14 @@
|
||||
package readerioresult
|
||||
|
||||
import (
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
RIOR "github.com/IBM/fp-go/v2/readerioresult"
|
||||
)
|
||||
|
||||
// Bracket makes sure that a resource is cleaned up in the event of an error. The release action is called regardless of
|
||||
// whether the body action returns and error or not.
|
||||
//
|
||||
//go:inline
|
||||
func Bracket[
|
||||
A, B, ANY any](
|
||||
|
||||
@@ -28,5 +31,5 @@ func Bracket[
|
||||
use Kleisli[A, B],
|
||||
release func(A, Either[B]) ReaderIOResult[ANY],
|
||||
) ReaderIOResult[B] {
|
||||
return RIOR.Bracket(acquire, use, release)
|
||||
return RIOR.Bracket(acquire, F.Flow2(use, WithContext), release)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"context"
|
||||
|
||||
CIOE "github.com/IBM/fp-go/v2/context/ioresult"
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/ioeither"
|
||||
)
|
||||
|
||||
@@ -34,9 +35,53 @@ import (
|
||||
// Returns a ReaderIOResult that checks for cancellation before executing.
|
||||
func WithContext[A any](ma ReaderIOResult[A]) ReaderIOResult[A] {
|
||||
return func(ctx context.Context) IOEither[A] {
|
||||
if err := context.Cause(ctx); err != nil {
|
||||
return ioeither.Left[A](err)
|
||||
if ctx.Err() != nil {
|
||||
return ioeither.Left[A](context.Cause(ctx))
|
||||
}
|
||||
return CIOE.WithContext(ctx, ma(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
// WithContextK wraps a Kleisli arrow with context cancellation checking.
|
||||
// This ensures that the computation checks for context cancellation before executing,
|
||||
// providing a convenient way to add cancellation awareness to Kleisli arrows.
|
||||
//
|
||||
// This is particularly useful when composing multiple Kleisli arrows where each step
|
||||
// should respect context cancellation.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The input type of the Kleisli arrow
|
||||
// - B: The output type of the Kleisli arrow
|
||||
//
|
||||
// Parameters:
|
||||
// - f: The Kleisli arrow to wrap with context checking
|
||||
//
|
||||
// Returns:
|
||||
// - A Kleisli arrow that checks for cancellation before executing
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// fetchUser := func(id int) ReaderIOResult[User] {
|
||||
// return func(ctx context.Context) IOResult[User] {
|
||||
// return func() Result[User] {
|
||||
// // Long-running operation
|
||||
// return result.Of(User{ID: id})
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Wrap with context checking
|
||||
// safeFetch := WithContextK(fetchUser)
|
||||
//
|
||||
// // If context is cancelled, returns immediately without executing fetchUser
|
||||
// ctx, cancel := context.WithCancel(context.Background())
|
||||
// cancel() // Cancel immediately
|
||||
// result := safeFetch(123)(ctx)() // Returns context.Canceled error
|
||||
//
|
||||
//go:inline
|
||||
func WithContextK[A, B any](f Kleisli[A, B]) Kleisli[A, B] {
|
||||
return F.Flow2(
|
||||
f,
|
||||
WithContext,
|
||||
)
|
||||
}
|
||||
|
||||
60
v2/context/readerioresult/circuitbreaker.go
Normal file
60
v2/context/readerioresult/circuitbreaker.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package readerioresult
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/IBM/fp-go/v2/circuitbreaker"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/retry"
|
||||
)
|
||||
|
||||
type (
|
||||
ClosedState = circuitbreaker.ClosedState
|
||||
|
||||
Env[T any] = Pair[IORef[circuitbreaker.BreakerState], ReaderIOResult[T]]
|
||||
|
||||
CircuitBreaker[T any] = State[Env[T], ReaderIOResult[T]]
|
||||
)
|
||||
|
||||
func MakeCircuitBreaker[T any](
|
||||
currentTime IO[time.Time],
|
||||
closedState ClosedState,
|
||||
checkError option.Kleisli[error, error],
|
||||
policy retry.RetryPolicy,
|
||||
metrics circuitbreaker.Metrics,
|
||||
) CircuitBreaker[T] {
|
||||
return circuitbreaker.MakeCircuitBreaker[error, T](
|
||||
Left,
|
||||
ChainFirstIOK,
|
||||
ChainFirstLeftIOK,
|
||||
FromIO,
|
||||
Flap,
|
||||
Flatten,
|
||||
|
||||
currentTime,
|
||||
closedState,
|
||||
circuitbreaker.MakeCircuitBreakerError,
|
||||
checkError,
|
||||
policy,
|
||||
metrics,
|
||||
)
|
||||
}
|
||||
|
||||
func MakeSingletonBreaker[T any](
|
||||
currentTime IO[time.Time],
|
||||
closedState ClosedState,
|
||||
checkError option.Kleisli[error, error],
|
||||
policy retry.RetryPolicy,
|
||||
metrics circuitbreaker.Metrics,
|
||||
) Operator[T, T] {
|
||||
return circuitbreaker.MakeSingletonBreaker(
|
||||
MakeCircuitBreaker[T](
|
||||
currentTime,
|
||||
closedState,
|
||||
checkError,
|
||||
policy,
|
||||
metrics,
|
||||
),
|
||||
closedState,
|
||||
)
|
||||
}
|
||||
246
v2/context/readerioresult/circuitbreaker_doc.md
Normal file
246
v2/context/readerioresult/circuitbreaker_doc.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# Circuit Breaker Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The `circuitbreaker.go` file provides a circuit breaker implementation for the `readerioresult` package. A circuit breaker is a design pattern used to detect failures and prevent cascading failures in distributed systems by temporarily blocking operations that are likely to fail.
|
||||
|
||||
## Package
|
||||
|
||||
```go
|
||||
package readerioresult
|
||||
```
|
||||
|
||||
This is part of the `context/readerioresult` package, which provides functional programming abstractions for operations that:
|
||||
- Depend on a `context.Context` (Reader aspect)
|
||||
- Perform side effects (IO aspect)
|
||||
- Can fail with an `error` (Result/Either aspect)
|
||||
|
||||
## Type Definitions
|
||||
|
||||
### ClosedState
|
||||
|
||||
```go
|
||||
type ClosedState = circuitbreaker.ClosedState
|
||||
```
|
||||
|
||||
A type alias for the circuit breaker's closed state. When the circuit is closed, requests are allowed to pass through normally. The closed state tracks success and failure counts to determine when to open the circuit.
|
||||
|
||||
### Env[T any]
|
||||
|
||||
```go
|
||||
type Env[T any] = Pair[IORef[circuitbreaker.BreakerState], ReaderIOResult[T]]
|
||||
```
|
||||
|
||||
The environment type for the circuit breaker state machine. It contains:
|
||||
- `IORef[circuitbreaker.BreakerState]`: A mutable reference to the current breaker state
|
||||
- `ReaderIOResult[T]`: The computation to be protected by the circuit breaker
|
||||
|
||||
### CircuitBreaker[T any]
|
||||
|
||||
```go
|
||||
type CircuitBreaker[T any] = State[Env[T], ReaderIOResult[T]]
|
||||
```
|
||||
|
||||
The main circuit breaker type. It's a state monad that:
|
||||
- Takes an environment containing the breaker state and the protected computation
|
||||
- Returns a new environment and a wrapped computation that respects the circuit breaker logic
|
||||
|
||||
## Functions
|
||||
|
||||
### MakeCircuitBreaker
|
||||
|
||||
```go
|
||||
func MakeCircuitBreaker[T any](
|
||||
currentTime IO[time.Time],
|
||||
closedState ClosedState,
|
||||
checkError option.Kleisli[error, error],
|
||||
policy retry.RetryPolicy,
|
||||
logger io.Kleisli[string, string],
|
||||
) CircuitBreaker[T]
|
||||
```
|
||||
|
||||
Creates a new circuit breaker with the specified configuration.
|
||||
|
||||
#### Parameters
|
||||
|
||||
- **currentTime** `IO[time.Time]`: A function that returns the current time. This can be a virtual timer for testing purposes, allowing you to control time progression in tests.
|
||||
|
||||
- **closedState** `ClosedState`: The initial closed state configuration. This defines:
|
||||
- Maximum number of failures before opening the circuit
|
||||
- Time window for counting failures
|
||||
- Other closed state parameters
|
||||
|
||||
- **checkError** `option.Kleisli[error, error]`: A function that determines whether an error should be counted as a failure. Returns:
|
||||
- `Some(error)`: The error should be counted as a failure
|
||||
- `None`: The error should be ignored (not counted as a failure)
|
||||
|
||||
This allows you to distinguish between transient errors (that should trigger circuit breaking) and permanent errors (that shouldn't).
|
||||
|
||||
- **policy** `retry.RetryPolicy`: The retry policy that determines:
|
||||
- How long to wait before attempting to close the circuit (reset time)
|
||||
- Exponential backoff or other delay strategies
|
||||
- Maximum number of retry attempts
|
||||
|
||||
- **logger** `io.Kleisli[string, string]`: A logging function for circuit breaker events. Receives log messages and performs side effects (like writing to a log file or console).
|
||||
|
||||
#### Returns
|
||||
|
||||
A `CircuitBreaker[T]` that wraps computations with circuit breaker logic.
|
||||
|
||||
#### Circuit Breaker States
|
||||
|
||||
The circuit breaker operates in three states:
|
||||
|
||||
1. **Closed**: Normal operation. Requests pass through. Failures are counted.
|
||||
- If failure threshold is exceeded, transitions to Open state
|
||||
|
||||
2. **Open**: Circuit is broken. Requests fail immediately without executing.
|
||||
- After reset time expires, transitions to Half-Open state
|
||||
|
||||
3. **Half-Open** (Canary): Testing if the service has recovered.
|
||||
- Allows a single test request (canary request)
|
||||
- If canary succeeds, transitions to Closed state
|
||||
- If canary fails, transitions back to Open state with extended reset time
|
||||
|
||||
#### Implementation Details
|
||||
|
||||
The function delegates to the generic `circuitbreaker.MakeCircuitBreaker` function, providing the necessary type-specific operations:
|
||||
|
||||
- **Left**: Creates a failed computation from an error
|
||||
- **ChainFirstIOK**: Chains an IO operation that runs for side effects on success
|
||||
- **ChainFirstLeftIOK**: Chains an IO operation that runs for side effects on failure
|
||||
- **FromIO**: Lifts an IO computation into ReaderIOResult
|
||||
- **Flap**: Applies a computation to a function
|
||||
- **Flatten**: Flattens nested ReaderIOResult structures
|
||||
|
||||
These operations allow the generic circuit breaker to work with the `ReaderIOResult` monad.
|
||||
|
||||
## Usage Example
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/IBM/fp-go/v2/circuitbreaker"
|
||||
"github.com/IBM/fp-go/v2/context/readerioresult"
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
"github.com/IBM/fp-go/v2/ioref"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/retry"
|
||||
)
|
||||
|
||||
// Create a circuit breaker configuration
|
||||
func createCircuitBreaker() readerioresult.CircuitBreaker[string] {
|
||||
// Use real time
|
||||
currentTime := func() time.Time { return time.Now() }
|
||||
|
||||
// Configure closed state: open after 5 failures in 10 seconds
|
||||
closedState := circuitbreaker.MakeClosedState(5, 10*time.Second)
|
||||
|
||||
// Check all errors (count all as failures)
|
||||
checkError := func(err error) option.Option[error] {
|
||||
return option.Some(err)
|
||||
}
|
||||
|
||||
// Retry policy: exponential backoff with max 5 retries
|
||||
policy := retry.Monoid.Concat(
|
||||
retry.LimitRetries(5),
|
||||
retry.ExponentialBackoff(100*time.Millisecond),
|
||||
)
|
||||
|
||||
// Simple logger
|
||||
logger := func(msg string) io.IO[string] {
|
||||
return func() string {
|
||||
fmt.Println("Circuit Breaker:", msg)
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
return readerioresult.MakeCircuitBreaker[string](
|
||||
currentTime,
|
||||
closedState,
|
||||
checkError,
|
||||
policy,
|
||||
logger,
|
||||
)
|
||||
}
|
||||
|
||||
// Use the circuit breaker
|
||||
func main() {
|
||||
cb := createCircuitBreaker()
|
||||
|
||||
// Create initial state
|
||||
stateRef := ioref.NewIORef(circuitbreaker.InitialState())
|
||||
|
||||
// Your protected operation
|
||||
operation := func(ctx context.Context) readerioresult.IOResult[string] {
|
||||
return func() readerioresult.Result[string] {
|
||||
// Your actual operation here
|
||||
return result.Of("success")
|
||||
}
|
||||
}
|
||||
|
||||
// Apply circuit breaker
|
||||
env := pair.MakePair(stateRef, operation)
|
||||
result := cb(env)
|
||||
|
||||
// Execute the protected operation
|
||||
ctx := context.Background()
|
||||
protectedOp := pair.Tail(result)
|
||||
outcome := protectedOp(ctx)()
|
||||
}
|
||||
```
|
||||
|
||||
## Testing with Virtual Timer
|
||||
|
||||
For testing, you can provide a virtual timer instead of `time.Now()`:
|
||||
|
||||
```go
|
||||
// Virtual timer for testing
|
||||
type VirtualTimer struct {
|
||||
current time.Time
|
||||
}
|
||||
|
||||
func (vt *VirtualTimer) Now() time.Time {
|
||||
return vt.current
|
||||
}
|
||||
|
||||
func (vt *VirtualTimer) Advance(d time.Duration) {
|
||||
vt.current = vt.current.Add(d)
|
||||
}
|
||||
|
||||
// Use in tests
|
||||
func TestCircuitBreaker(t *testing.T) {
|
||||
vt := &VirtualTimer{current: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}
|
||||
|
||||
currentTime := func() time.Time { return vt.Now() }
|
||||
|
||||
cb := readerioresult.MakeCircuitBreaker[string](
|
||||
currentTime,
|
||||
closedState,
|
||||
checkError,
|
||||
policy,
|
||||
logger,
|
||||
)
|
||||
|
||||
// Test circuit breaker behavior
|
||||
// Advance time as needed
|
||||
vt.Advance(5 * time.Second)
|
||||
}
|
||||
```
|
||||
|
||||
## Related Types
|
||||
|
||||
- `circuitbreaker.BreakerState`: The internal state of the circuit breaker (closed or open)
|
||||
- `circuitbreaker.ClosedState`: Configuration for the closed state
|
||||
- `retry.RetryPolicy`: Policy for retry delays and limits
|
||||
- `option.Kleisli[error, error]`: Function type for error checking
|
||||
- `io.Kleisli[string, string]`: Function type for logging
|
||||
|
||||
## See Also
|
||||
|
||||
- `circuitbreaker` package: Generic circuit breaker implementation
|
||||
- `retry` package: Retry policies and strategies
|
||||
- `readerioresult` package: Core ReaderIOResult monad operations
|
||||
974
v2/context/readerioresult/circuitbreaker_test.go
Normal file
974
v2/context/readerioresult/circuitbreaker_test.go
Normal file
@@ -0,0 +1,974 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package readerioresult
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/IBM/fp-go/v2/array"
|
||||
"github.com/IBM/fp-go/v2/circuitbreaker"
|
||||
"github.com/IBM/fp-go/v2/ioref"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
"github.com/IBM/fp-go/v2/retry"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// VirtualTimer provides a controllable time source for testing
|
||||
type VirtualTimer struct {
|
||||
mu sync.Mutex
|
||||
current time.Time
|
||||
}
|
||||
|
||||
// NewVirtualTimer creates a new virtual timer starting at the given time
|
||||
func NewVirtualTimer(start time.Time) *VirtualTimer {
|
||||
return &VirtualTimer{current: start}
|
||||
}
|
||||
|
||||
// Now returns the current virtual time
|
||||
func (vt *VirtualTimer) Now() time.Time {
|
||||
vt.mu.Lock()
|
||||
defer vt.mu.Unlock()
|
||||
return vt.current
|
||||
}
|
||||
|
||||
// Advance moves the virtual time forward by the given duration
|
||||
func (vt *VirtualTimer) Advance(d time.Duration) {
|
||||
vt.mu.Lock()
|
||||
defer vt.mu.Unlock()
|
||||
vt.current = vt.current.Add(d)
|
||||
}
|
||||
|
||||
// Set sets the virtual time to a specific value
|
||||
func (vt *VirtualTimer) Set(t time.Time) {
|
||||
vt.mu.Lock()
|
||||
defer vt.mu.Unlock()
|
||||
vt.current = t
|
||||
}
|
||||
|
||||
// Helper function to create a test logger that collects messages
|
||||
func testMetrics(_ *[]string) circuitbreaker.Metrics {
|
||||
return circuitbreaker.MakeMetricsFromLogger("testMetrics", log.Default())
|
||||
}
|
||||
|
||||
// Helper function to create a simple closed state
|
||||
func testCBClosedState() circuitbreaker.ClosedState {
|
||||
return circuitbreaker.MakeClosedStateCounter(3)
|
||||
}
|
||||
|
||||
// Helper function to create a test retry policy
|
||||
func testCBRetryPolicy() retry.RetryPolicy {
|
||||
return retry.Monoid.Concat(
|
||||
retry.LimitRetries(3),
|
||||
retry.ExponentialBackoff(100*time.Millisecond),
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function that checks all errors
|
||||
func checkAllErrors(err error) option.Option[error] {
|
||||
return option.Some(err)
|
||||
}
|
||||
|
||||
// Helper function that ignores specific errors
|
||||
func ignoreSpecificError(ignoredMsg string) func(error) option.Option[error] {
|
||||
return func(err error) option.Option[error] {
|
||||
if err.Error() == ignoredMsg {
|
||||
return option.None[error]()
|
||||
}
|
||||
return option.Some(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCircuitBreaker_SuccessfulOperation tests that successful operations
|
||||
// pass through the circuit breaker without issues
|
||||
func TestCircuitBreaker_SuccessfulOperation(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
var logMessages []string
|
||||
|
||||
cb := MakeCircuitBreaker[string](
|
||||
vt.Now,
|
||||
testCBClosedState(),
|
||||
checkAllErrors,
|
||||
testCBRetryPolicy(),
|
||||
testMetrics(&logMessages),
|
||||
)
|
||||
|
||||
// Create initial state
|
||||
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
|
||||
|
||||
// Successful operation
|
||||
operation := Of("success")
|
||||
|
||||
// Apply circuit breaker
|
||||
env := pair.MakePair(stateRef, operation)
|
||||
resultEnv := cb(env)
|
||||
|
||||
// Execute
|
||||
ctx := t.Context()
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
outcome := protectedOp(ctx)()
|
||||
|
||||
assert.Equal(t, result.Of("success"), outcome)
|
||||
}
|
||||
|
||||
// TestCircuitBreaker_SingleFailure tests that a single failure is handled
|
||||
// but doesn't open the circuit
|
||||
func TestCircuitBreaker_SingleFailure(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
var logMessages []string
|
||||
|
||||
cb := MakeCircuitBreaker[string](
|
||||
vt.Now,
|
||||
testCBClosedState(),
|
||||
checkAllErrors,
|
||||
testCBRetryPolicy(),
|
||||
testMetrics(&logMessages),
|
||||
)
|
||||
|
||||
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
|
||||
|
||||
expError := errors.New("operation failed")
|
||||
|
||||
// Failing operation
|
||||
operation := Left[string](expError)
|
||||
|
||||
env := pair.MakePair(stateRef, operation)
|
||||
resultEnv := cb(env)
|
||||
|
||||
ctx := t.Context()
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
outcome := protectedOp(ctx)()
|
||||
|
||||
assert.Equal(t, result.Left[string](expError), outcome)
|
||||
|
||||
// Circuit should still be closed after one failure
|
||||
state := ioref.Read(stateRef)()
|
||||
assert.True(t, circuitbreaker.IsClosed(state))
|
||||
}
|
||||
|
||||
// TestCircuitBreaker_OpensAfterThreshold tests that the circuit opens
|
||||
// after exceeding the failure threshold
|
||||
func TestCircuitBreaker_OpensAfterThreshold(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
var logMessages []string
|
||||
|
||||
cb := MakeCircuitBreaker[string](
|
||||
vt.Now,
|
||||
testCBClosedState(), // Opens after 3 failures
|
||||
checkAllErrors,
|
||||
testCBRetryPolicy(),
|
||||
testMetrics(&logMessages),
|
||||
)
|
||||
|
||||
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
|
||||
|
||||
expError := errors.New("operation failed")
|
||||
|
||||
// Failing operation
|
||||
operation := Left[string](expError)
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
// Execute 3 failures to open the circuit
|
||||
for range 3 {
|
||||
env := pair.MakePair(stateRef, operation)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
outcome := protectedOp(ctx)()
|
||||
assert.Equal(t, result.Left[string](expError), outcome)
|
||||
}
|
||||
|
||||
// Circuit should now be open
|
||||
state := ioref.Read(stateRef)()
|
||||
assert.True(t, circuitbreaker.IsOpen(state))
|
||||
|
||||
// Next request should fail immediately with circuit breaker error
|
||||
env := pair.MakePair(stateRef, operation)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
outcome := protectedOp(ctx)()
|
||||
|
||||
assert.True(t, result.IsLeft(outcome))
|
||||
_, err := result.Unwrap(outcome)
|
||||
var cbErr *circuitbreaker.CircuitBreakerError
|
||||
assert.ErrorAs(t, err, &cbErr)
|
||||
}
|
||||
|
||||
// TestCircuitBreaker_HalfOpenAfterResetTime tests that the circuit
|
||||
// transitions to half-open state after the reset time
|
||||
func TestCircuitBreaker_HalfOpenAfterResetTime(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
var logMessages []string
|
||||
|
||||
cb := MakeCircuitBreaker[string](
|
||||
vt.Now,
|
||||
testCBClosedState(),
|
||||
checkAllErrors,
|
||||
testCBRetryPolicy(),
|
||||
testMetrics(&logMessages),
|
||||
)
|
||||
|
||||
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
|
||||
|
||||
expError := errors.New("operation failed")
|
||||
|
||||
// Failing operation
|
||||
failingOp := Left[string](expError)
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
// Open the circuit with 3 failures
|
||||
for range 3 {
|
||||
env := pair.MakePair(stateRef, failingOp)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
outcome := protectedOp(ctx)()
|
||||
|
||||
assert.Equal(t, result.Left[string](expError), outcome)
|
||||
}
|
||||
|
||||
// Verify circuit is open
|
||||
state := ioref.Read(stateRef)()
|
||||
assert.True(t, circuitbreaker.IsOpen(state))
|
||||
|
||||
// Advance time past the reset time (exponential backoff starts at 100ms)
|
||||
vt.Advance(200 * time.Millisecond)
|
||||
|
||||
// Now create a successful operation for the canary request
|
||||
successOp := Of("success")
|
||||
|
||||
// Next request should be a canary request
|
||||
env := pair.MakePair(stateRef, successOp)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
outcome := protectedOp(ctx)()
|
||||
|
||||
// Canary should succeed
|
||||
assert.Equal(t, result.Of("success"), outcome)
|
||||
|
||||
// Circuit should now be closed again
|
||||
state = ioref.Read(stateRef)()
|
||||
assert.True(t, circuitbreaker.IsClosed(state))
|
||||
}
|
||||
|
||||
// TestCircuitBreaker_CanaryFailureExtendsOpenTime tests that a failed
|
||||
// canary request extends the open time
|
||||
func TestCircuitBreaker_CanaryFailureExtendsOpenTime(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
var logMessages []string
|
||||
|
||||
cb := MakeCircuitBreaker[string](
|
||||
vt.Now,
|
||||
testCBClosedState(),
|
||||
checkAllErrors,
|
||||
testCBRetryPolicy(),
|
||||
testMetrics(&logMessages),
|
||||
)
|
||||
|
||||
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
|
||||
|
||||
expError := errors.New("operation failed")
|
||||
|
||||
// Failing operation
|
||||
failingOp := Left[string](expError)
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
// Open the circuit
|
||||
for range 3 {
|
||||
env := pair.MakePair(stateRef, failingOp)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
outcome := protectedOp(ctx)()
|
||||
assert.Equal(t, result.Left[string](expError), outcome)
|
||||
}
|
||||
|
||||
// Advance time to trigger canary
|
||||
vt.Advance(200 * time.Millisecond)
|
||||
|
||||
// Canary request fails
|
||||
env := pair.MakePair(stateRef, failingOp)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
outcome := protectedOp(ctx)()
|
||||
|
||||
assert.True(t, result.IsLeft(outcome))
|
||||
|
||||
// Circuit should still be open
|
||||
state := ioref.Read(stateRef)()
|
||||
assert.True(t, circuitbreaker.IsOpen(state))
|
||||
|
||||
// Immediate next request should fail with circuit breaker error
|
||||
env = pair.MakePair(stateRef, failingOp)
|
||||
resultEnv = cb(env)
|
||||
protectedOp = pair.Tail(resultEnv)
|
||||
outcome = protectedOp(ctx)()
|
||||
|
||||
assert.True(t, result.IsLeft(outcome))
|
||||
_, err := result.Unwrap(outcome)
|
||||
var cbErr *circuitbreaker.CircuitBreakerError
|
||||
assert.ErrorAs(t, err, &cbErr)
|
||||
}
|
||||
|
||||
// TestCircuitBreaker_IgnoredErrorsDoNotCount tests that errors filtered
|
||||
// by checkError don't count toward opening the circuit
|
||||
func TestCircuitBreaker_IgnoredErrorsDoNotCount(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
var logMessages []string
|
||||
|
||||
// Ignore "ignorable error"
|
||||
checkError := ignoreSpecificError("ignorable error")
|
||||
|
||||
cb := MakeCircuitBreaker[string](
|
||||
vt.Now,
|
||||
testCBClosedState(),
|
||||
checkError,
|
||||
testCBRetryPolicy(),
|
||||
testMetrics(&logMessages),
|
||||
)
|
||||
|
||||
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
|
||||
|
||||
ctx := t.Context()
|
||||
ignorableError := errors.New("ignorable error")
|
||||
|
||||
// Execute 5 ignorable errors
|
||||
ignorableOp := Left[string](ignorableError)
|
||||
|
||||
for range 5 {
|
||||
env := pair.MakePair(stateRef, ignorableOp)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
outcome := protectedOp(ctx)()
|
||||
assert.Equal(t, result.Left[string](ignorableError), outcome)
|
||||
}
|
||||
|
||||
// Circuit should still be closed
|
||||
state := ioref.Read(stateRef)()
|
||||
assert.True(t, circuitbreaker.IsClosed(state))
|
||||
|
||||
realError := errors.New("real error")
|
||||
|
||||
// Now send a real error
|
||||
realErrorOp := Left[string](realError)
|
||||
|
||||
env := pair.MakePair(stateRef, realErrorOp)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
outcome := protectedOp(ctx)()
|
||||
|
||||
assert.Equal(t, result.Left[string](realError), outcome)
|
||||
|
||||
// Circuit should still be closed (only 1 counted error)
|
||||
state = ioref.Read(stateRef)()
|
||||
assert.True(t, circuitbreaker.IsClosed(state))
|
||||
}
|
||||
|
||||
// TestCircuitBreaker_MixedSuccessAndFailure tests the circuit behavior
|
||||
// with a mix of successful and failed operations
|
||||
func TestCircuitBreaker_MixedSuccessAndFailure(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
var logMessages []string
|
||||
|
||||
cb := MakeCircuitBreaker[string](
|
||||
vt.Now,
|
||||
testCBClosedState(),
|
||||
checkAllErrors,
|
||||
testCBRetryPolicy(),
|
||||
testMetrics(&logMessages),
|
||||
)
|
||||
|
||||
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
successOp := Of("success")
|
||||
expError := errors.New("failure")
|
||||
|
||||
failOp := Left[string](expError)
|
||||
|
||||
// Pattern: fail, fail, success, fail
|
||||
ops := array.From(failOp, failOp, successOp, failOp)
|
||||
|
||||
for _, op := range ops {
|
||||
env := pair.MakePair(stateRef, op)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
_ = protectedOp(ctx)()
|
||||
}
|
||||
|
||||
// Circuit should still be closed (success resets the count)
|
||||
state := ioref.Read(stateRef)()
|
||||
assert.True(t, circuitbreaker.IsClosed(state))
|
||||
}
|
||||
|
||||
// TestCircuitBreaker_ConcurrentOperations tests that the circuit breaker
|
||||
// handles concurrent operations correctly
|
||||
func TestCircuitBreaker_ConcurrentOperations(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
var logMessages []string
|
||||
|
||||
cb := MakeCircuitBreaker[int](
|
||||
vt.Now,
|
||||
testCBClosedState(),
|
||||
checkAllErrors,
|
||||
testCBRetryPolicy(),
|
||||
testMetrics(&logMessages),
|
||||
)
|
||||
|
||||
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]Result[int], 10)
|
||||
|
||||
// Launch 10 concurrent operations
|
||||
for i := range 10 {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
|
||||
op := Of(idx)
|
||||
|
||||
env := pair.MakePair(stateRef, op)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
results[idx] = protectedOp(ctx)()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// All operations should succeed
|
||||
for i, res := range results {
|
||||
assert.True(t, result.IsRight(res), "Operation %d should succeed", i)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCircuitBreaker_DifferentTypes tests that the circuit breaker works
|
||||
// with different result types
|
||||
func TestCircuitBreaker_DifferentTypes(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
var logMessages []string
|
||||
|
||||
// Test with int
|
||||
cbInt := MakeCircuitBreaker[int](
|
||||
vt.Now,
|
||||
testCBClosedState(),
|
||||
checkAllErrors,
|
||||
testCBRetryPolicy(),
|
||||
testMetrics(&logMessages),
|
||||
)
|
||||
|
||||
stateRefInt := circuitbreaker.MakeClosedIORef(testCBClosedState())()
|
||||
|
||||
opInt := Of(42)
|
||||
|
||||
ctx := t.Context()
|
||||
envInt := pair.MakePair(stateRefInt, opInt)
|
||||
resultEnvInt := cbInt(envInt)
|
||||
protectedOpInt := pair.Tail(resultEnvInt)
|
||||
outcomeInt := protectedOpInt(ctx)()
|
||||
|
||||
assert.Equal(t, result.Of(42), outcomeInt)
|
||||
|
||||
// Test with struct
|
||||
type User struct {
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
|
||||
cbUser := MakeCircuitBreaker[User](
|
||||
vt.Now,
|
||||
testCBClosedState(),
|
||||
checkAllErrors,
|
||||
testCBRetryPolicy(),
|
||||
testMetrics(&logMessages),
|
||||
)
|
||||
|
||||
stateRefUser := circuitbreaker.MakeClosedIORef(testCBClosedState())()
|
||||
|
||||
opUser := Of(User{ID: 1, Name: "Alice"})
|
||||
|
||||
envUser := pair.MakePair(stateRefUser, opUser)
|
||||
resultEnvUser := cbUser(envUser)
|
||||
protectedOpUser := pair.Tail(resultEnvUser)
|
||||
outcomeUser := protectedOpUser(ctx)()
|
||||
|
||||
require.Equal(t, result.Of(User{ID: 1, Name: "Alice"}), outcomeUser)
|
||||
}
|
||||
|
||||
// TestCircuitBreaker_VirtualTimerAdvancement tests that the virtual timer
|
||||
// correctly controls time-based behavior
|
||||
func TestCircuitBreaker_VirtualTimerAdvancement(t *testing.T) {
|
||||
startTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
vt := NewVirtualTimer(startTime)
|
||||
|
||||
// Verify initial time
|
||||
assert.Equal(t, startTime, vt.Now())
|
||||
|
||||
// Advance by 1 hour
|
||||
vt.Advance(1 * time.Hour)
|
||||
assert.Equal(t, startTime.Add(1*time.Hour), vt.Now())
|
||||
|
||||
// Advance by 30 minutes
|
||||
vt.Advance(30 * time.Minute)
|
||||
assert.Equal(t, startTime.Add(90*time.Minute), vt.Now())
|
||||
|
||||
// Set to specific time
|
||||
newTime := time.Date(2024, 6, 15, 10, 30, 0, 0, time.UTC)
|
||||
vt.Set(newTime)
|
||||
assert.Equal(t, newTime, vt.Now())
|
||||
}
|
||||
|
||||
// TestCircuitBreaker_InitialState tests that the circuit starts in closed state
|
||||
func TestCircuitBreaker_InitialState(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
var logMessages []string
|
||||
|
||||
cb := MakeCircuitBreaker[string](
|
||||
vt.Now,
|
||||
testCBClosedState(),
|
||||
checkAllErrors,
|
||||
testCBRetryPolicy(),
|
||||
testMetrics(&logMessages),
|
||||
)
|
||||
|
||||
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
|
||||
|
||||
// Check initial state is closed
|
||||
state := ioref.Read(stateRef)()
|
||||
assert.True(t, circuitbreaker.IsClosed(state), "Circuit should start in closed state")
|
||||
|
||||
// First operation should execute normally
|
||||
op := Of("first operation")
|
||||
|
||||
ctx := t.Context()
|
||||
env := pair.MakePair(stateRef, op)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
outcome := protectedOp(ctx)()
|
||||
|
||||
assert.Equal(t, result.Of("first operation"), outcome)
|
||||
}
|
||||
|
||||
// TestCircuitBreaker_ErrorMessageFormat tests that circuit breaker errors
|
||||
// have appropriate error messages
|
||||
func TestCircuitBreaker_ErrorMessageFormat(t *testing.T) {
|
||||
vt := NewVirtualTimer(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
var logMessages []string
|
||||
|
||||
cb := MakeCircuitBreaker[string](
|
||||
vt.Now,
|
||||
testCBClosedState(),
|
||||
checkAllErrors,
|
||||
testCBRetryPolicy(),
|
||||
testMetrics(&logMessages),
|
||||
)
|
||||
|
||||
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
expError := errors.New("service unavailable")
|
||||
|
||||
failOp := Left[string](expError)
|
||||
|
||||
// Open the circuit
|
||||
for range 3 {
|
||||
env := pair.MakePair(stateRef, failOp)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
_ = protectedOp(ctx)()
|
||||
}
|
||||
|
||||
// Next request should fail with circuit breaker error
|
||||
env := pair.MakePair(stateRef, failOp)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
outcome := protectedOp(ctx)()
|
||||
|
||||
assert.True(t, result.IsLeft[string](outcome))
|
||||
|
||||
// Error message should indicate circuit breaker is open
|
||||
_, err := result.Unwrap(outcome)
|
||||
errMsg := err.Error()
|
||||
assert.Contains(t, errMsg, "circuit", "Error should mention circuit breaker")
|
||||
}
|
||||
|
||||
// RequestSpec defines a virtual request with timing and outcome information
|
||||
type RequestSpec struct {
|
||||
ID int // Unique identifier for the request
|
||||
StartTime time.Duration // Virtual start time relative to test start
|
||||
Duration time.Duration // How long the request takes to execute
|
||||
ShouldFail bool // Whether this request should fail
|
||||
}
|
||||
|
||||
// RequestResult captures the outcome of a request execution
|
||||
type RequestResult struct {
|
||||
ID int
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
Success bool
|
||||
Error error
|
||||
CircuitBreakerError bool // True if failed due to circuit breaker being open
|
||||
}
|
||||
|
||||
// TestCircuitBreaker_ConcurrentBatchWithThresholdExceeded tests a complex
|
||||
// concurrent scenario where:
|
||||
// 1. Initial requests succeed
|
||||
// 2. A batch of failures exceeds the threshold, opening the circuit
|
||||
// 3. Subsequent requests fail immediately due to open circuit
|
||||
// 4. After timeout, a canary request succeeds
|
||||
// 5. Following requests succeed again
|
||||
func TestCircuitBreaker_ConcurrentBatchWithThresholdExceeded(t *testing.T) {
|
||||
startTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
vt := NewVirtualTimer(startTime)
|
||||
var logMessages []string
|
||||
|
||||
// Circuit opens after 3 failures, with exponential backoff starting at 100ms
|
||||
cb := MakeCircuitBreaker[string](
|
||||
vt.Now,
|
||||
testCBClosedState(), // Opens after 3 failures
|
||||
checkAllErrors,
|
||||
testCBRetryPolicy(), // 100ms initial backoff
|
||||
testMetrics(&logMessages),
|
||||
)
|
||||
|
||||
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
|
||||
ctx := t.Context()
|
||||
|
||||
// Define the request sequence
|
||||
// Phase 1: Initial successes (0-100ms)
|
||||
// Phase 2: Failures that exceed threshold (100-200ms) - should open circuit
|
||||
// Phase 3: Requests during open circuit (200-300ms) - should fail immediately
|
||||
// Phase 4: After timeout (400ms+) - canary succeeds, then more successes
|
||||
requests := []RequestSpec{
|
||||
// Phase 1: Initial successful requests
|
||||
{ID: 1, StartTime: 0 * time.Millisecond, Duration: 10 * time.Millisecond, ShouldFail: false},
|
||||
{ID: 2, StartTime: 20 * time.Millisecond, Duration: 10 * time.Millisecond, ShouldFail: false},
|
||||
|
||||
// Phase 2: Sequential failures that exceed threshold (3 failures)
|
||||
{ID: 3, StartTime: 100 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: true},
|
||||
{ID: 4, StartTime: 110 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: true},
|
||||
{ID: 5, StartTime: 120 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: true},
|
||||
{ID: 6, StartTime: 130 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: true},
|
||||
|
||||
// Phase 3: Requests during open circuit - should fail with circuit breaker error
|
||||
{ID: 7, StartTime: 200 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false},
|
||||
{ID: 8, StartTime: 210 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false},
|
||||
{ID: 9, StartTime: 220 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false},
|
||||
|
||||
// Phase 4: After reset timeout (100ms backoff from last failure at ~125ms = ~225ms)
|
||||
// Wait longer to ensure we're past the reset time
|
||||
{ID: 10, StartTime: 400 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false}, // Canary succeeds
|
||||
{ID: 11, StartTime: 410 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false},
|
||||
{ID: 12, StartTime: 420 * time.Millisecond, Duration: 5 * time.Millisecond, ShouldFail: false},
|
||||
}
|
||||
|
||||
results := make([]RequestResult, len(requests))
|
||||
|
||||
// Execute requests sequentially but model them as if they were concurrent
|
||||
// by advancing the virtual timer to each request's start time
|
||||
for i, req := range requests {
|
||||
// Set virtual time to request start time
|
||||
vt.Set(startTime.Add(req.StartTime))
|
||||
|
||||
// Create the operation based on spec
|
||||
var op ReaderIOResult[string]
|
||||
if req.ShouldFail {
|
||||
op = Left[string](errors.New("operation failed"))
|
||||
} else {
|
||||
op = Of("success")
|
||||
}
|
||||
|
||||
// Apply circuit breaker
|
||||
env := pair.MakePair(stateRef, op)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
|
||||
// Record start time
|
||||
execStartTime := vt.Now()
|
||||
|
||||
// Execute the operation
|
||||
outcome := protectedOp(ctx)()
|
||||
|
||||
// Advance time by operation duration
|
||||
vt.Advance(req.Duration)
|
||||
execEndTime := vt.Now()
|
||||
|
||||
// Analyze the result
|
||||
isSuccess := result.IsRight(outcome)
|
||||
var err error
|
||||
var isCBError bool
|
||||
|
||||
if !isSuccess {
|
||||
_, err = result.Unwrap(outcome)
|
||||
var cbErr *circuitbreaker.CircuitBreakerError
|
||||
isCBError = errors.As(err, &cbErr)
|
||||
}
|
||||
|
||||
results[i] = RequestResult{
|
||||
ID: req.ID,
|
||||
StartTime: execStartTime,
|
||||
EndTime: execEndTime,
|
||||
Success: isSuccess,
|
||||
Error: err,
|
||||
CircuitBreakerError: isCBError,
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Phase 1: Initial requests should succeed
|
||||
assert.True(t, results[0].Success, "Request 1 should succeed")
|
||||
assert.True(t, results[1].Success, "Request 2 should succeed")
|
||||
|
||||
// Verify Phase 2: Failures should be recorded (first 3 fail with actual error)
|
||||
// The 4th might fail with CB error if circuit opened fast enough
|
||||
assert.False(t, results[2].Success, "Request 3 should fail")
|
||||
assert.False(t, results[3].Success, "Request 4 should fail")
|
||||
assert.False(t, results[4].Success, "Request 5 should fail")
|
||||
|
||||
// At least the first 3 failures should be actual operation failures, not CB errors
|
||||
actualFailures := 0
|
||||
for i := 2; i <= 4; i++ {
|
||||
if !results[i].CircuitBreakerError {
|
||||
actualFailures++
|
||||
}
|
||||
}
|
||||
assert.GreaterOrEqual(t, actualFailures, 3, "At least 3 actual operation failures should occur")
|
||||
|
||||
// Verify Phase 3: Requests during open circuit should fail with circuit breaker error
|
||||
for i := 6; i <= 8; i++ {
|
||||
assert.False(t, results[i].Success, "Request %d should fail during open circuit", results[i].ID)
|
||||
assert.True(t, results[i].CircuitBreakerError, "Request %d should fail with circuit breaker error", results[i].ID)
|
||||
}
|
||||
|
||||
// Verify Phase 4: After timeout, canary and subsequent requests should succeed
|
||||
assert.True(t, results[9].Success, "Request 10 (canary) should succeed")
|
||||
assert.True(t, results[10].Success, "Request 11 should succeed after circuit closes")
|
||||
assert.True(t, results[11].Success, "Request 12 should succeed after circuit closes")
|
||||
|
||||
// Verify final state is closed
|
||||
finalState := ioref.Read(stateRef)()
|
||||
assert.True(t, circuitbreaker.IsClosed(finalState), "Circuit should be closed at the end")
|
||||
|
||||
// Log summary for debugging
|
||||
t.Logf("Test completed with %d requests", len(results))
|
||||
successCount := 0
|
||||
cbErrorCount := 0
|
||||
actualErrorCount := 0
|
||||
|
||||
for _, r := range results {
|
||||
if r.Success {
|
||||
successCount++
|
||||
} else if r.CircuitBreakerError {
|
||||
cbErrorCount++
|
||||
} else {
|
||||
actualErrorCount++
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Summary: %d successes, %d circuit breaker errors, %d actual errors",
|
||||
successCount, cbErrorCount, actualErrorCount)
|
||||
}
|
||||
|
||||
// TestCircuitBreaker_ConcurrentHighLoad tests circuit breaker behavior
|
||||
// under high concurrent load with mixed success/failure patterns
|
||||
func TestCircuitBreaker_ConcurrentHighLoad(t *testing.T) {
|
||||
startTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
vt := NewVirtualTimer(startTime)
|
||||
var logMessages []string
|
||||
|
||||
cb := MakeCircuitBreaker[int](
|
||||
vt.Now,
|
||||
testCBClosedState(),
|
||||
checkAllErrors,
|
||||
testCBRetryPolicy(),
|
||||
testMetrics(&logMessages),
|
||||
)
|
||||
|
||||
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
|
||||
ctx := t.Context()
|
||||
|
||||
// Create a large batch of 50 requests
|
||||
// Pattern: success, success, fail, fail, fail, fail, success, success, ...
|
||||
// This ensures we have initial successes, then failures to open circuit,
|
||||
// then more requests that hit the open circuit
|
||||
numRequests := 50
|
||||
|
||||
results := make([]bool, numRequests)
|
||||
cbErrors := make([]bool, numRequests)
|
||||
|
||||
// Execute requests with controlled timing
|
||||
for i := range numRequests {
|
||||
// Advance time slightly for each request
|
||||
vt.Advance(10 * time.Millisecond)
|
||||
|
||||
// Pattern: 2 success, 4 failures, repeat
|
||||
// This ensures we exceed the threshold (3 failures) early on
|
||||
shouldFail := (i%6) >= 2 && (i%6) < 6
|
||||
|
||||
var op ReaderIOResult[int]
|
||||
if shouldFail {
|
||||
op = Left[int](errors.New("simulated failure"))
|
||||
} else {
|
||||
op = Of(i)
|
||||
}
|
||||
|
||||
env := pair.MakePair(stateRef, op)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
outcome := protectedOp(ctx)()
|
||||
|
||||
results[i] = result.IsRight(outcome)
|
||||
|
||||
if !results[i] {
|
||||
_, err := result.Unwrap(outcome)
|
||||
var cbErr *circuitbreaker.CircuitBreakerError
|
||||
cbErrors[i] = errors.As(err, &cbErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Count outcomes
|
||||
successCount := 0
|
||||
failureCount := 0
|
||||
cbErrorCount := 0
|
||||
|
||||
for i := range numRequests {
|
||||
if results[i] {
|
||||
successCount++
|
||||
} else {
|
||||
failureCount++
|
||||
if cbErrors[i] {
|
||||
cbErrorCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("High load test: %d total requests", numRequests)
|
||||
t.Logf("Results: %d successes, %d failures (%d circuit breaker errors)",
|
||||
successCount, failureCount, cbErrorCount)
|
||||
|
||||
// Verify that circuit breaker activated (some requests failed due to open circuit)
|
||||
assert.Greater(t, cbErrorCount, 0, "Circuit breaker should have opened and blocked some requests")
|
||||
|
||||
// Verify that not all requests failed (some succeeded before circuit opened)
|
||||
assert.Greater(t, successCount, 0, "Some requests should have succeeded")
|
||||
}
|
||||
|
||||
// TestCircuitBreaker_TrueConcurrentRequests tests actual concurrent execution
|
||||
// with proper synchronization
|
||||
func TestCircuitBreaker_TrueConcurrentRequests(t *testing.T) {
|
||||
startTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
vt := NewVirtualTimer(startTime)
|
||||
var logMessages []string
|
||||
|
||||
cb := MakeCircuitBreaker[int](
|
||||
vt.Now,
|
||||
testCBClosedState(),
|
||||
checkAllErrors,
|
||||
testCBRetryPolicy(),
|
||||
testMetrics(&logMessages),
|
||||
)
|
||||
|
||||
stateRef := circuitbreaker.MakeClosedIORef(testCBClosedState())()
|
||||
ctx := t.Context()
|
||||
|
||||
// Launch 20 concurrent requests
|
||||
numRequests := 20
|
||||
var wg sync.WaitGroup
|
||||
results := make([]bool, numRequests)
|
||||
cbErrors := make([]bool, numRequests)
|
||||
|
||||
// First, send some successful requests
|
||||
for i := range 5 {
|
||||
op := Of(i)
|
||||
env := pair.MakePair(stateRef, op)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
outcome := protectedOp(ctx)()
|
||||
results[i] = result.IsRight(outcome)
|
||||
}
|
||||
|
||||
// Now send concurrent failures to open the circuit
|
||||
for i := 5; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
op := Left[int](errors.New("concurrent failure"))
|
||||
env := pair.MakePair(stateRef, op)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
outcome := protectedOp(ctx)()
|
||||
results[idx] = result.IsRight(outcome)
|
||||
if !results[idx] {
|
||||
_, err := result.Unwrap(outcome)
|
||||
var cbErr *circuitbreaker.CircuitBreakerError
|
||||
cbErrors[idx] = errors.As(err, &cbErr)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Now send more requests that should hit the open circuit
|
||||
for i := 10; i < numRequests; i++ {
|
||||
op := Of(i)
|
||||
env := pair.MakePair(stateRef, op)
|
||||
resultEnv := cb(env)
|
||||
protectedOp := pair.Tail(resultEnv)
|
||||
outcome := protectedOp(ctx)()
|
||||
results[i] = result.IsRight(outcome)
|
||||
if !results[i] {
|
||||
_, err := result.Unwrap(outcome)
|
||||
var cbErr *circuitbreaker.CircuitBreakerError
|
||||
cbErrors[i] = errors.As(err, &cbErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Count outcomes
|
||||
successCount := 0
|
||||
failureCount := 0
|
||||
cbErrorCount := 0
|
||||
|
||||
for i := range numRequests {
|
||||
if results[i] {
|
||||
successCount++
|
||||
} else {
|
||||
failureCount++
|
||||
if cbErrors[i] {
|
||||
cbErrorCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Concurrent test: %d total requests", numRequests)
|
||||
t.Logf("Results: %d successes, %d failures (%d circuit breaker errors)",
|
||||
successCount, failureCount, cbErrorCount)
|
||||
|
||||
// Verify initial successes
|
||||
assert.Equal(t, 5, successCount, "First 5 requests should succeed")
|
||||
|
||||
// Verify that circuit breaker opened and blocked some requests
|
||||
assert.Greater(t, cbErrorCount, 0, "Circuit breaker should have opened and blocked some requests")
|
||||
}
|
||||
63
v2/context/readerioresult/consumer.go
Normal file
63
v2/context/readerioresult/consumer.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package readerioresult
|
||||
|
||||
import "github.com/IBM/fp-go/v2/io"
|
||||
|
||||
// ChainConsumer chains a consumer function into a ReaderIOResult computation, discarding the original value.
|
||||
// This is useful for performing side effects (like logging or metrics) that consume a value
|
||||
// but don't produce a meaningful result. The computation continues with an empty struct.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of value to consume
|
||||
//
|
||||
// Parameters:
|
||||
// - c: A consumer function that performs side effects on the value
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that chains the consumer and returns struct{}
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// logUser := func(u User) {
|
||||
// log.Printf("Processing user: %s", u.Name)
|
||||
// }
|
||||
//
|
||||
// pipeline := F.Pipe2(
|
||||
// fetchUser(123),
|
||||
// ChainConsumer(logUser),
|
||||
// )
|
||||
//
|
||||
//go:inline
|
||||
func ChainConsumer[A any](c Consumer[A]) Operator[A, struct{}] {
|
||||
return ChainIOK(io.FromConsumer(c))
|
||||
}
|
||||
|
||||
// ChainFirstConsumer chains a consumer function into a ReaderIOResult computation, preserving the original value.
|
||||
// This is useful for performing side effects (like logging or metrics) while passing the value through unchanged.
|
||||
//
|
||||
// The consumer is executed for its side effects, but the original value is returned.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of value to consume and return
|
||||
//
|
||||
// Parameters:
|
||||
// - c: A consumer function that performs side effects on the value
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that chains the consumer and returns the original value
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// logUser := func(u User) {
|
||||
// log.Printf("User: %s", u.Name)
|
||||
// }
|
||||
//
|
||||
// pipeline := F.Pipe3(
|
||||
// fetchUser(123),
|
||||
// ChainFirstConsumer(logUser), // Logs but passes user through
|
||||
// Map(func(u User) string { return u.Email }),
|
||||
// )
|
||||
//
|
||||
//go:inline
|
||||
func ChainFirstConsumer[A any](c Consumer[A]) Operator[A, A] {
|
||||
return ChainFirstIOK(io.FromConsumer(c))
|
||||
}
|
||||
@@ -44,11 +44,11 @@ var (
|
||||
)
|
||||
|
||||
// Close closes an object
|
||||
func Close[C io.Closer](c C) RIOE.ReaderIOResult[any] {
|
||||
func Close[C io.Closer](c C) RIOE.ReaderIOResult[struct{}] {
|
||||
return F.Pipe2(
|
||||
c,
|
||||
IOEF.Close[C],
|
||||
RIOE.FromIOEither[any],
|
||||
RIOE.FromIOEither[struct{}],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
51
v2/context/readerioresult/filter.go
Normal file
51
v2/context/readerioresult/filter.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package readerioresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
RIOR "github.com/IBM/fp-go/v2/readerioresult"
|
||||
)
|
||||
|
||||
// FilterOrElse filters a ReaderIOResult value based on a predicate.
|
||||
// This is a convenience wrapper around readerioresult.FilterOrElse that fixes
|
||||
// the context type to context.Context.
|
||||
//
|
||||
// If the predicate returns true for the Right value, it passes through unchanged.
|
||||
// If the predicate returns false, it transforms the Right value into a Left (error) using onFalse.
|
||||
// Left values are passed through unchanged.
|
||||
//
|
||||
// Parameters:
|
||||
// - pred: A predicate function that tests the Right value
|
||||
// - onFalse: A function that converts the failing value into an error
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that filters ReaderIOResult values based on the predicate
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Validate that a number is positive
|
||||
// isPositive := N.MoreThan(0)
|
||||
// onNegative := func(n int) error { return fmt.Errorf("%d is not positive", n) }
|
||||
//
|
||||
// filter := readerioresult.FilterOrElse(isPositive, onNegative)
|
||||
// result := filter(readerioresult.Right(42))(context.Background())()
|
||||
//
|
||||
//go:inline
|
||||
func FilterOrElse[A any](pred Predicate[A], onFalse func(A) error) Operator[A, A] {
|
||||
return RIOR.FilterOrElse[context.Context](pred, onFalse)
|
||||
}
|
||||
@@ -83,7 +83,7 @@ import (
|
||||
// )
|
||||
//
|
||||
//go:inline
|
||||
func SequenceReader[R, A any](ma ReaderIOResult[Reader[R, A]]) reader.Kleisli[context.Context, R, IOResult[A]] {
|
||||
func SequenceReader[R, A any](ma ReaderIOResult[Reader[R, A]]) Kleisli[R, A] {
|
||||
return RIOR.SequenceReader(ma)
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ func SequenceReader[R, A any](ma ReaderIOResult[Reader[R, A]]) reader.Kleisli[co
|
||||
// )
|
||||
//
|
||||
//go:inline
|
||||
func SequenceReaderIO[R, A any](ma ReaderIOResult[RIO.ReaderIO[R, A]]) reader.Kleisli[context.Context, R, IOResult[A]] {
|
||||
func SequenceReaderIO[R, A any](ma ReaderIOResult[RIO.ReaderIO[R, A]]) Kleisli[R, A] {
|
||||
return RIOR.SequenceReaderIO(ma)
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ func SequenceReaderIO[R, A any](ma ReaderIOResult[RIO.ReaderIO[R, A]]) reader.Kl
|
||||
// )
|
||||
//
|
||||
//go:inline
|
||||
func SequenceReaderResult[R, A any](ma ReaderIOResult[RR.ReaderResult[R, A]]) reader.Kleisli[context.Context, R, IOResult[A]] {
|
||||
func SequenceReaderResult[R, A any](ma ReaderIOResult[RR.ReaderResult[R, A]]) Kleisli[R, A] {
|
||||
return RIOR.SequenceReaderEither(ma)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/IBM/fp-go/v2/context/readerio"
|
||||
"github.com/IBM/fp-go/v2/function"
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
"github.com/IBM/fp-go/v2/logging"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
)
|
||||
|
||||
@@ -55,14 +56,14 @@ var (
|
||||
// loggingCounter is an atomic counter that generates unique LoggingIDs
|
||||
loggingCounter atomic.Uint64
|
||||
|
||||
loggingContextValue = function.Bind2nd(context.Context.Value, any(loggingContextKey))
|
||||
loggingContextValue = F.Bind2nd(context.Context.Value, any(loggingContextKey))
|
||||
|
||||
withLoggingContextValue = function.Bind2of3(context.WithValue)(any(loggingContextKey))
|
||||
withLoggingContextValue = F.Bind2of3(context.WithValue)(any(loggingContextKey))
|
||||
|
||||
// getLoggingContext retrieves the logging information (start time and ID) from the context.
|
||||
// It returns a Pair containing the start time and the logging ID.
|
||||
// This function assumes the context contains logging information; it will panic if not present.
|
||||
getLoggingContext = function.Flow3(
|
||||
getLoggingContext = F.Flow3(
|
||||
loggingContextValue,
|
||||
option.ToType[loggingContext],
|
||||
option.GetOrElse(getDefaultLoggingContext),
|
||||
@@ -86,7 +87,7 @@ func getDefaultLoggingContext() loggingContext {
|
||||
// Returns:
|
||||
// - An endomorphism that adds the logging context to a context.Context
|
||||
func withLoggingContext(lctx loggingContext) Endomorphism[context.Context] {
|
||||
return function.Bind2nd(withLoggingContextValue, any(lctx))
|
||||
return F.Bind2nd(withLoggingContextValue, any(lctx))
|
||||
}
|
||||
|
||||
// LogEntryExitF creates a customizable operator that wraps a ReaderIOResult computation with entry/exit callbacks.
|
||||
@@ -134,7 +135,7 @@ func withLoggingContext(lctx loggingContext) Endomorphism[context.Context] {
|
||||
// return func(ctx context.Context) IO[any] {
|
||||
// return func() any {
|
||||
// reqID := ctx.Value("requestID").(RequestID)
|
||||
// return function.Pipe1(
|
||||
// return F.Pipe1(
|
||||
// res,
|
||||
// result.Fold(
|
||||
// func(err error) any {
|
||||
@@ -171,7 +172,7 @@ func withLoggingContext(lctx loggingContext) Endomorphism[context.Context] {
|
||||
// startTime := ctx.Value("startTime").(time.Time)
|
||||
// duration := time.Since(startTime).Seconds()
|
||||
//
|
||||
// return function.Pipe1(
|
||||
// return F.Pipe1(
|
||||
// res,
|
||||
// result.Fold(
|
||||
// func(err error) any {
|
||||
@@ -204,12 +205,12 @@ func LogEntryExitF[A, ANY any](
|
||||
onEntry ReaderIO[context.Context],
|
||||
onExit readerio.Kleisli[Result[A], ANY],
|
||||
) Operator[A, A] {
|
||||
bracket := function.Bind13of3(readerio.Bracket[context.Context, Result[A], ANY])(onEntry, func(newCtx context.Context, res Result[A]) ReaderIO[ANY] {
|
||||
bracket := F.Bind13of3(readerio.Bracket[context.Context, Result[A], ANY])(onEntry, func(newCtx context.Context, res Result[A]) ReaderIO[ANY] {
|
||||
return readerio.FromIO(onExit(res)(newCtx)) // Get the exit callback for this result
|
||||
})
|
||||
|
||||
return func(src ReaderIOResult[A]) ReaderIOResult[A] {
|
||||
return bracket(function.Flow2(
|
||||
return bracket(F.Flow2(
|
||||
src,
|
||||
FromIOResult,
|
||||
))
|
||||
@@ -308,7 +309,7 @@ func onExitAny(
|
||||
return nil
|
||||
}
|
||||
|
||||
return function.Pipe1(
|
||||
return F.Pipe1(
|
||||
res,
|
||||
result.Fold(onError, onSuccess),
|
||||
)
|
||||
@@ -375,7 +376,7 @@ func LogEntryExitWithCallback[A any](
|
||||
|
||||
return LogEntryExitF(
|
||||
onEntry(logLevel, cb, nameAttr),
|
||||
function.Flow2(
|
||||
F.Flow2(
|
||||
result.MapTo[A, any](nil),
|
||||
onExitAny(logLevel, nameAttr),
|
||||
),
|
||||
@@ -495,6 +496,19 @@ func LogEntryExit[A any](name string) Operator[A, A] {
|
||||
return LogEntryExitWithCallback[A](slog.LevelInfo, logging.GetLoggerFromContext, name)
|
||||
}
|
||||
|
||||
func curriedLog(
|
||||
logLevel slog.Level,
|
||||
cb func(context.Context) *slog.Logger,
|
||||
message string) func(slog.Attr) func(context.Context) func() struct{} {
|
||||
return F.Curry2(func(a slog.Attr, ctx context.Context) func() struct{} {
|
||||
logger := cb(ctx)
|
||||
return func() struct{} {
|
||||
logger.LogAttrs(ctx, logLevel, message, a)
|
||||
return struct{}{}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// SLogWithCallback creates a Kleisli arrow that logs a Result value (success or error) with a custom logger and log level.
|
||||
//
|
||||
// This function logs both successful values and errors, making it useful for debugging and monitoring
|
||||
@@ -558,26 +572,18 @@ func LogEntryExit[A any](name string) Operator[A, A] {
|
||||
func SLogWithCallback[A any](
|
||||
logLevel slog.Level,
|
||||
cb func(context.Context) *slog.Logger,
|
||||
message string) readerio.Kleisli[Result[A], Result[A]] {
|
||||
return func(ma Result[A]) ReaderIOResult[A] {
|
||||
return func(ctx context.Context) IOResult[A] {
|
||||
// logger
|
||||
logger := cb(ctx)
|
||||
return func() Result[A] {
|
||||
return result.MonadFold(
|
||||
ma,
|
||||
func(e error) Result[A] {
|
||||
logger.LogAttrs(ctx, logLevel, message, slog.Any("error", e))
|
||||
return ma
|
||||
},
|
||||
func(a A) Result[A] {
|
||||
logger.LogAttrs(ctx, logLevel, message, slog.Any("value", a))
|
||||
return ma
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
message string) Kleisli[Result[A], A] {
|
||||
|
||||
return F.Pipe1(
|
||||
F.Flow2(
|
||||
// create the attribute to log depending on the condition
|
||||
result.ToSLogAttr[A](),
|
||||
// create an `IO` that logs the attribute
|
||||
curriedLog(logLevel, cb, message),
|
||||
),
|
||||
// preserve the original context
|
||||
reader.Chain(reader.Sequence(readerio.MapTo[struct{}, Result[A]])),
|
||||
)
|
||||
}
|
||||
|
||||
// SLog creates a Kleisli arrow that logs a Result value (success or error) with a message.
|
||||
@@ -637,7 +643,7 @@ func SLogWithCallback[A any](
|
||||
// For logging only successful values, use TapSLog instead.
|
||||
//
|
||||
//go:inline
|
||||
func SLog[A any](message string) readerio.Kleisli[Result[A], Result[A]] {
|
||||
func SLog[A any](message string) Kleisli[Result[A], A] {
|
||||
return SLogWithCallback[A](slog.LevelInfo, logging.GetLoggerFromContext, message)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/IBM/fp-go/v2/context/readerio"
|
||||
"github.com/IBM/fp-go/v2/context/readerresult"
|
||||
"github.com/IBM/fp-go/v2/either"
|
||||
"github.com/IBM/fp-go/v2/errors"
|
||||
@@ -26,8 +27,8 @@ import (
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
"github.com/IBM/fp-go/v2/ioeither"
|
||||
"github.com/IBM/fp-go/v2/ioresult"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
"github.com/IBM/fp-go/v2/readerio"
|
||||
RIOR "github.com/IBM/fp-go/v2/readerioresult"
|
||||
"github.com/IBM/fp-go/v2/readeroption"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
@@ -151,7 +152,7 @@ func MapTo[A, B any](b B) Operator[A, B] {
|
||||
//
|
||||
//go:inline
|
||||
func MonadChain[A, B any](ma ReaderIOResult[A], f Kleisli[A, B]) ReaderIOResult[B] {
|
||||
return RIOR.MonadChain(ma, f)
|
||||
return RIOR.MonadChain(ma, WithContextK(f))
|
||||
}
|
||||
|
||||
// Chain sequences two [ReaderIOResult] computations, where the second depends on the result of the first.
|
||||
@@ -164,7 +165,7 @@ func MonadChain[A, B any](ma ReaderIOResult[A], f Kleisli[A, B]) ReaderIOResult[
|
||||
//
|
||||
//go:inline
|
||||
func Chain[A, B any](f Kleisli[A, B]) Operator[A, B] {
|
||||
return RIOR.Chain(f)
|
||||
return RIOR.Chain(WithContextK(f))
|
||||
}
|
||||
|
||||
// MonadChainFirst sequences two [ReaderIOResult] computations but returns the result of the first.
|
||||
@@ -178,12 +179,12 @@ func Chain[A, B any](f Kleisli[A, B]) Operator[A, B] {
|
||||
//
|
||||
//go:inline
|
||||
func MonadChainFirst[A, B any](ma ReaderIOResult[A], f Kleisli[A, B]) ReaderIOResult[A] {
|
||||
return RIOR.MonadChainFirst(ma, f)
|
||||
return RIOR.MonadChainFirst(ma, WithContextK(f))
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func MonadTap[A, B any](ma ReaderIOResult[A], f Kleisli[A, B]) ReaderIOResult[A] {
|
||||
return RIOR.MonadTap(ma, f)
|
||||
return RIOR.MonadTap(ma, WithContextK(f))
|
||||
}
|
||||
|
||||
// ChainFirst sequences two [ReaderIOResult] computations but returns the result of the first.
|
||||
@@ -196,12 +197,12 @@ func MonadTap[A, B any](ma ReaderIOResult[A], f Kleisli[A, B]) ReaderIOResult[A]
|
||||
//
|
||||
//go:inline
|
||||
func ChainFirst[A, B any](f Kleisli[A, B]) Operator[A, A] {
|
||||
return RIOR.ChainFirst(f)
|
||||
return RIOR.ChainFirst(WithContextK(f))
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func Tap[A, B any](f Kleisli[A, B]) Operator[A, A] {
|
||||
return RIOR.Tap(f)
|
||||
return RIOR.Tap(WithContextK(f))
|
||||
}
|
||||
|
||||
// Of creates a [ReaderIOResult] that always succeeds with the given value.
|
||||
@@ -383,7 +384,7 @@ func Ask() ReaderIOResult[context.Context] {
|
||||
// Returns a new ReaderIOResult with the chained computation.
|
||||
//
|
||||
//go:inline
|
||||
func MonadChainEitherK[A, B any](ma ReaderIOResult[A], f func(A) Either[B]) ReaderIOResult[B] {
|
||||
func MonadChainEitherK[A, B any](ma ReaderIOResult[A], f either.Kleisli[error, A, B]) ReaderIOResult[B] {
|
||||
return RIOR.MonadChainEitherK(ma, f)
|
||||
}
|
||||
|
||||
@@ -396,7 +397,12 @@ func MonadChainEitherK[A, B any](ma ReaderIOResult[A], f func(A) Either[B]) Read
|
||||
// Returns a function that chains the Either-returning function.
|
||||
//
|
||||
//go:inline
|
||||
func ChainEitherK[A, B any](f func(A) Either[B]) Operator[A, B] {
|
||||
func ChainEitherK[A, B any](f either.Kleisli[error, A, B]) Operator[A, B] {
|
||||
return RIOR.ChainEitherK[context.Context](f)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func ChainResultK[A, B any](f either.Kleisli[error, A, B]) Operator[A, B] {
|
||||
return RIOR.ChainEitherK[context.Context](f)
|
||||
}
|
||||
|
||||
@@ -410,12 +416,12 @@ func ChainEitherK[A, B any](f func(A) Either[B]) Operator[A, B] {
|
||||
// Returns a ReaderIOResult with the original value if both computations succeed.
|
||||
//
|
||||
//go:inline
|
||||
func MonadChainFirstEitherK[A, B any](ma ReaderIOResult[A], f func(A) Either[B]) ReaderIOResult[A] {
|
||||
func MonadChainFirstEitherK[A, B any](ma ReaderIOResult[A], f either.Kleisli[error, A, B]) ReaderIOResult[A] {
|
||||
return RIOR.MonadChainFirstEitherK(ma, f)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func MonadTapEitherK[A, B any](ma ReaderIOResult[A], f func(A) Either[B]) ReaderIOResult[A] {
|
||||
func MonadTapEitherK[A, B any](ma ReaderIOResult[A], f either.Kleisli[error, A, B]) ReaderIOResult[A] {
|
||||
return RIOR.MonadTapEitherK(ma, f)
|
||||
}
|
||||
|
||||
@@ -428,12 +434,12 @@ func MonadTapEitherK[A, B any](ma ReaderIOResult[A], f func(A) Either[B]) Reader
|
||||
// Returns a function that chains the Either-returning function.
|
||||
//
|
||||
//go:inline
|
||||
func ChainFirstEitherK[A, B any](f func(A) Either[B]) Operator[A, A] {
|
||||
func ChainFirstEitherK[A, B any](f either.Kleisli[error, A, B]) Operator[A, A] {
|
||||
return RIOR.ChainFirstEitherK[context.Context](f)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func TapEitherK[A, B any](f func(A) Either[B]) Operator[A, A] {
|
||||
func TapEitherK[A, B any](f either.Kleisli[error, A, B]) Operator[A, A] {
|
||||
return RIOR.TapEitherK[context.Context](f)
|
||||
}
|
||||
|
||||
@@ -446,7 +452,7 @@ func TapEitherK[A, B any](f func(A) Either[B]) Operator[A, A] {
|
||||
// Returns a function that chains Option-returning functions into ReaderIOResult.
|
||||
//
|
||||
//go:inline
|
||||
func ChainOptionK[A, B any](onNone func() error) func(func(A) Option[B]) Operator[A, B] {
|
||||
func ChainOptionK[A, B any](onNone func() error) func(option.Kleisli[A, B]) Operator[A, B] {
|
||||
return RIOR.ChainOptionK[context.Context, A, B](onNone)
|
||||
}
|
||||
|
||||
@@ -528,7 +534,7 @@ func Never[A any]() ReaderIOResult[A] {
|
||||
// Returns a new ReaderIOResult with the chained IO computation.
|
||||
//
|
||||
//go:inline
|
||||
func MonadChainIOK[A, B any](ma ReaderIOResult[A], f func(A) IO[B]) ReaderIOResult[B] {
|
||||
func MonadChainIOK[A, B any](ma ReaderIOResult[A], f io.Kleisli[A, B]) ReaderIOResult[B] {
|
||||
return RIOR.MonadChainIOK(ma, f)
|
||||
}
|
||||
|
||||
@@ -541,7 +547,7 @@ func MonadChainIOK[A, B any](ma ReaderIOResult[A], f func(A) IO[B]) ReaderIOResu
|
||||
// Returns a function that chains the IO-returning function.
|
||||
//
|
||||
//go:inline
|
||||
func ChainIOK[A, B any](f func(A) IO[B]) Operator[A, B] {
|
||||
func ChainIOK[A, B any](f io.Kleisli[A, B]) Operator[A, B] {
|
||||
return RIOR.ChainIOK[context.Context](f)
|
||||
}
|
||||
|
||||
@@ -555,12 +561,12 @@ func ChainIOK[A, B any](f func(A) IO[B]) Operator[A, B] {
|
||||
// Returns a ReaderIOResult with the original value after executing the IO.
|
||||
//
|
||||
//go:inline
|
||||
func MonadChainFirstIOK[A, B any](ma ReaderIOResult[A], f func(A) IO[B]) ReaderIOResult[A] {
|
||||
func MonadChainFirstIOK[A, B any](ma ReaderIOResult[A], f io.Kleisli[A, B]) ReaderIOResult[A] {
|
||||
return RIOR.MonadChainFirstIOK(ma, f)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func MonadTapIOK[A, B any](ma ReaderIOResult[A], f func(A) IO[B]) ReaderIOResult[A] {
|
||||
func MonadTapIOK[A, B any](ma ReaderIOResult[A], f io.Kleisli[A, B]) ReaderIOResult[A] {
|
||||
return RIOR.MonadTapIOK(ma, f)
|
||||
}
|
||||
|
||||
@@ -573,12 +579,12 @@ func MonadTapIOK[A, B any](ma ReaderIOResult[A], f func(A) IO[B]) ReaderIOResult
|
||||
// Returns a function that chains the IO-returning function.
|
||||
//
|
||||
//go:inline
|
||||
func ChainFirstIOK[A, B any](f func(A) IO[B]) Operator[A, A] {
|
||||
func ChainFirstIOK[A, B any](f io.Kleisli[A, B]) Operator[A, A] {
|
||||
return RIOR.ChainFirstIOK[context.Context](f)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func TapIOK[A, B any](f func(A) IO[B]) Operator[A, A] {
|
||||
func TapIOK[A, B any](f io.Kleisli[A, B]) Operator[A, A] {
|
||||
return RIOR.TapIOK[context.Context](f)
|
||||
}
|
||||
|
||||
@@ -591,7 +597,7 @@ func TapIOK[A, B any](f func(A) IO[B]) Operator[A, A] {
|
||||
// Returns a function that chains the IOResult-returning function.
|
||||
//
|
||||
//go:inline
|
||||
func ChainIOEitherK[A, B any](f func(A) IOResult[B]) Operator[A, B] {
|
||||
func ChainIOEitherK[A, B any](f ioresult.Kleisli[A, B]) Operator[A, B] {
|
||||
return RIOR.ChainIOEitherK[context.Context](f)
|
||||
}
|
||||
|
||||
@@ -754,7 +760,7 @@ func Flap[B, A any](a A) Operator[func(A) B, B] {
|
||||
//
|
||||
//go:inline
|
||||
func Fold[A, B any](onLeft Kleisli[error, B], onRight Kleisli[A, B]) Operator[A, B] {
|
||||
return RIOR.Fold(onLeft, onRight)
|
||||
return RIOR.Fold(function.Flow2(onLeft, WithContext), function.Flow2(onRight, WithContext))
|
||||
}
|
||||
|
||||
// GetOrElse extracts the value from a [ReaderIOResult], providing a default via a function if it fails.
|
||||
@@ -766,7 +772,7 @@ func Fold[A, B any](onLeft Kleisli[error, B], onRight Kleisli[A, B]) Operator[A,
|
||||
// Returns a function that converts a ReaderIOResult to a ReaderIO.
|
||||
//
|
||||
//go:inline
|
||||
func GetOrElse[A any](onLeft func(error) ReaderIO[A]) func(ReaderIOResult[A]) ReaderIO[A] {
|
||||
func GetOrElse[A any](onLeft readerio.Kleisli[error, A]) func(ReaderIOResult[A]) ReaderIO[A] {
|
||||
return RIOR.GetOrElse(onLeft)
|
||||
}
|
||||
|
||||
@@ -859,32 +865,32 @@ func TapReaderResultK[A, B any](f readerresult.Kleisli[A, B]) Operator[A, A] {
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func MonadChainReaderIOK[A, B any](ma ReaderIOResult[A], f readerio.Kleisli[context.Context, A, B]) ReaderIOResult[B] {
|
||||
func MonadChainReaderIOK[A, B any](ma ReaderIOResult[A], f readerio.Kleisli[A, B]) ReaderIOResult[B] {
|
||||
return RIOR.MonadChainReaderIOK(ma, f)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func ChainReaderIOK[A, B any](f readerio.Kleisli[context.Context, A, B]) Operator[A, B] {
|
||||
func ChainReaderIOK[A, B any](f readerio.Kleisli[A, B]) Operator[A, B] {
|
||||
return RIOR.ChainReaderIOK(f)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func MonadChainFirstReaderIOK[A, B any](ma ReaderIOResult[A], f readerio.Kleisli[context.Context, A, B]) ReaderIOResult[A] {
|
||||
func MonadChainFirstReaderIOK[A, B any](ma ReaderIOResult[A], f readerio.Kleisli[A, B]) ReaderIOResult[A] {
|
||||
return RIOR.MonadChainFirstReaderIOK(ma, f)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func MonadTapReaderIOK[A, B any](ma ReaderIOResult[A], f readerio.Kleisli[context.Context, A, B]) ReaderIOResult[A] {
|
||||
func MonadTapReaderIOK[A, B any](ma ReaderIOResult[A], f readerio.Kleisli[A, B]) ReaderIOResult[A] {
|
||||
return RIOR.MonadTapReaderIOK(ma, f)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func ChainFirstReaderIOK[A, B any](f readerio.Kleisli[context.Context, A, B]) Operator[A, A] {
|
||||
func ChainFirstReaderIOK[A, B any](f readerio.Kleisli[A, B]) Operator[A, A] {
|
||||
return RIOR.ChainFirstReaderIOK(f)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func TapReaderIOK[A, B any](f readerio.Kleisli[context.Context, A, B]) Operator[A, A] {
|
||||
func TapReaderIOK[A, B any](f readerio.Kleisli[A, B]) Operator[A, A] {
|
||||
return RIOR.TapReaderIOK(f)
|
||||
}
|
||||
|
||||
@@ -914,15 +920,15 @@ func Read[A any](r context.Context) func(ReaderIOResult[A]) IOResult[A] {
|
||||
//
|
||||
//go:inline
|
||||
func MonadChainLeft[A any](fa ReaderIOResult[A], f Kleisli[error, A]) ReaderIOResult[A] {
|
||||
return RIOR.MonadChainLeft(fa, f)
|
||||
return RIOR.MonadChainLeft(fa, WithContextK(f))
|
||||
}
|
||||
|
||||
// ChainLeft is the curried version of [MonadChainLeft].
|
||||
// It returns a function that chains a computation on the left (error) side of a [ReaderIOResult].
|
||||
//
|
||||
//go:inline
|
||||
func ChainLeft[A any](f Kleisli[error, A]) func(ReaderIOResult[A]) ReaderIOResult[A] {
|
||||
return RIOR.ChainLeft(f)
|
||||
func ChainLeft[A any](f Kleisli[error, A]) Operator[A, A] {
|
||||
return RIOR.ChainLeft(WithContextK(f))
|
||||
}
|
||||
|
||||
// MonadChainFirstLeft chains a computation on the left (error) side but always returns the original error.
|
||||
@@ -935,12 +941,12 @@ func ChainLeft[A any](f Kleisli[error, A]) func(ReaderIOResult[A]) ReaderIOResul
|
||||
//
|
||||
//go:inline
|
||||
func MonadChainFirstLeft[A, B any](ma ReaderIOResult[A], f Kleisli[error, B]) ReaderIOResult[A] {
|
||||
return RIOR.MonadChainFirstLeft(ma, f)
|
||||
return RIOR.MonadChainFirstLeft(ma, WithContextK(f))
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func MonadTapLeft[A, B any](ma ReaderIOResult[A], f Kleisli[error, B]) ReaderIOResult[A] {
|
||||
return RIOR.MonadTapLeft(ma, f)
|
||||
return RIOR.MonadTapLeft(ma, WithContextK(f))
|
||||
}
|
||||
|
||||
// ChainFirstLeft is the curried version of [MonadChainFirstLeft].
|
||||
@@ -952,12 +958,22 @@ func MonadTapLeft[A, B any](ma ReaderIOResult[A], f Kleisli[error, B]) ReaderIOR
|
||||
//
|
||||
//go:inline
|
||||
func ChainFirstLeft[A, B any](f Kleisli[error, B]) Operator[A, A] {
|
||||
return RIOR.ChainFirstLeft[A](f)
|
||||
return RIOR.ChainFirstLeft[A](WithContextK(f))
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func TapLeft[A, B any](f Kleisli[error, B]) Operator[A, A] {
|
||||
return RIOR.TapLeft[A](f)
|
||||
return RIOR.TapLeft[A](WithContextK(f))
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func ChainFirstLeftIOK[A, B any](f io.Kleisli[error, B]) Operator[A, A] {
|
||||
return RIOR.ChainFirstLeftIOK[A, context.Context](f)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func TapLeftIOK[A, B any](f io.Kleisli[error, B]) Operator[A, A] {
|
||||
return RIOR.TapLeftIOK[A, context.Context](f)
|
||||
}
|
||||
|
||||
// Local transforms the context.Context environment before passing it to a ReaderIOResult computation.
|
||||
|
||||
@@ -235,7 +235,7 @@ func TestApPar(t *testing.T) {
|
||||
func TestFromPredicate(t *testing.T) {
|
||||
t.Run("Predicate true", func(t *testing.T) {
|
||||
pred := FromPredicate(
|
||||
func(x int) bool { return x > 0 },
|
||||
N.MoreThan(0),
|
||||
func(x int) error { return fmt.Errorf("value %d is not positive", x) },
|
||||
)
|
||||
result := pred(5)
|
||||
@@ -244,7 +244,7 @@ func TestFromPredicate(t *testing.T) {
|
||||
|
||||
t.Run("Predicate false", func(t *testing.T) {
|
||||
pred := FromPredicate(
|
||||
func(x int) bool { return x > 0 },
|
||||
N.MoreThan(0),
|
||||
func(x int) error { return fmt.Errorf("value %d is not positive", x) },
|
||||
)
|
||||
result := pred(-5)
|
||||
@@ -567,15 +567,13 @@ func TestMemoize(t *testing.T) {
|
||||
res1 := computation(context.Background())()
|
||||
assert.True(t, E.IsRight(res1))
|
||||
val1 := E.ToOption(res1)
|
||||
v1, _ := O.Unwrap(val1)
|
||||
assert.Equal(t, 1, v1)
|
||||
assert.Equal(t, O.Of(1), val1)
|
||||
|
||||
// Second execution should return cached value
|
||||
res2 := computation(context.Background())()
|
||||
assert.True(t, E.IsRight(res2))
|
||||
val2 := E.ToOption(res2)
|
||||
v2, _ := O.Unwrap(val2)
|
||||
assert.Equal(t, 1, v2)
|
||||
assert.Equal(t, O.Of(1), val2)
|
||||
|
||||
// Counter should only be incremented once
|
||||
assert.Equal(t, 1, counter)
|
||||
@@ -739,9 +737,7 @@ func TestTraverseArray(t *testing.T) {
|
||||
res := result(context.Background())()
|
||||
assert.True(t, E.IsRight(res))
|
||||
arrOpt := E.ToOption(res)
|
||||
assert.True(t, O.IsSome(arrOpt))
|
||||
resultArr, _ := O.Unwrap(arrOpt)
|
||||
assert.Equal(t, []int{2, 4, 6}, resultArr)
|
||||
assert.Equal(t, O.Of([]int{2, 4, 6}), arrOpt)
|
||||
})
|
||||
|
||||
t.Run("TraverseArray with error", func(t *testing.T) {
|
||||
@@ -765,9 +761,7 @@ func TestSequenceArray(t *testing.T) {
|
||||
res := result(context.Background())()
|
||||
assert.True(t, E.IsRight(res))
|
||||
arrOpt := E.ToOption(res)
|
||||
assert.True(t, O.IsSome(arrOpt))
|
||||
resultArr, _ := O.Unwrap(arrOpt)
|
||||
assert.Equal(t, []int{1, 2, 3}, resultArr)
|
||||
assert.Equal(t, O.Of([]int{1, 2, 3}), arrOpt)
|
||||
}
|
||||
|
||||
func TestTraverseRecord(t *testing.T) {
|
||||
|
||||
183
v2/context/readerioresult/rec.go
Normal file
183
v2/context/readerioresult/rec.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package readerioresult
|
||||
|
||||
import (
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
RIOR "github.com/IBM/fp-go/v2/readerioresult"
|
||||
)
|
||||
|
||||
// TailRec implements stack-safe tail recursion for the context-aware ReaderIOResult monad.
|
||||
//
|
||||
// This function enables recursive computations that combine four powerful concepts:
|
||||
// - Context awareness: Automatic cancellation checking via [context.Context]
|
||||
// - Environment dependency (Reader aspect): Access to configuration, context, or dependencies
|
||||
// - Side effects (IO aspect): Logging, file I/O, network calls, etc.
|
||||
// - Error handling (Either aspect): Computations that can fail with an error
|
||||
//
|
||||
// The function uses an iterative loop to execute the recursion, making it safe for deep
|
||||
// or unbounded recursion without risking stack overflow. Additionally, it integrates
|
||||
// context cancellation checking through [WithContext], ensuring that recursive computations
|
||||
// can be cancelled gracefully.
|
||||
//
|
||||
// # How It Works
|
||||
//
|
||||
// TailRec takes a Kleisli arrow that returns Trampoline[A, B]:
|
||||
// - Bounce(A): Continue recursion with the new state A
|
||||
// - Land(B): Terminate recursion successfully and return the final result B
|
||||
//
|
||||
// The function wraps each iteration with [WithContext] to ensure context cancellation
|
||||
// is checked before each recursive step. If the context is cancelled, the recursion
|
||||
// terminates early with a context cancellation error.
|
||||
//
|
||||
// # Type Parameters
|
||||
//
|
||||
// - A: The state type that changes during recursion
|
||||
// - B: The final result type when recursion terminates successfully
|
||||
//
|
||||
// # Parameters
|
||||
//
|
||||
// - f: A Kleisli arrow (A => ReaderIOResult[Trampoline[A, B]]) that:
|
||||
// - Takes the current state A
|
||||
// - Returns a ReaderIOResult that depends on [context.Context]
|
||||
// - Can fail with error (Left in the outer Either)
|
||||
// - Produces Trampoline[A, B] to control recursion flow (Right in the outer Either)
|
||||
//
|
||||
// # Returns
|
||||
//
|
||||
// A Kleisli arrow (A => ReaderIOResult[B]) that:
|
||||
// - Takes an initial state A
|
||||
// - Returns a ReaderIOResult that requires [context.Context]
|
||||
// - Can fail with error or context cancellation
|
||||
// - Produces the final result B after recursion completes
|
||||
//
|
||||
// # Context Cancellation
|
||||
//
|
||||
// Unlike the base [readerioresult.TailRec], this version automatically integrates
|
||||
// context cancellation checking:
|
||||
// - Each recursive iteration checks if the context is cancelled
|
||||
// - If cancelled, recursion terminates immediately with a cancellation error
|
||||
// - This prevents runaway recursive computations in cancelled contexts
|
||||
// - Enables responsive cancellation for long-running recursive operations
|
||||
//
|
||||
// # Use Cases
|
||||
//
|
||||
// 1. Cancellable recursive algorithms:
|
||||
// - Tree traversals that can be cancelled mid-operation
|
||||
// - Graph algorithms with timeout requirements
|
||||
// - Recursive parsers that respect cancellation
|
||||
//
|
||||
// 2. Long-running recursive computations:
|
||||
// - File system traversals with cancellation support
|
||||
// - Network operations with timeout handling
|
||||
// - Database operations with connection timeout awareness
|
||||
//
|
||||
// 3. Interactive recursive operations:
|
||||
// - User-initiated operations that can be cancelled
|
||||
// - Background tasks with cancellation support
|
||||
// - Streaming operations with graceful shutdown
|
||||
//
|
||||
// # Example: Cancellable Countdown
|
||||
//
|
||||
// countdownStep := func(n int) readerioresult.ReaderIOResult[tailrec.Trampoline[int, string]] {
|
||||
// return func(ctx context.Context) ioeither.IOEither[error, tailrec.Trampoline[int, string]] {
|
||||
// return func() either.Either[error, tailrec.Trampoline[int, string]] {
|
||||
// if n <= 0 {
|
||||
// return either.Right[error](tailrec.Land[int]("Done!"))
|
||||
// }
|
||||
// // Simulate some work
|
||||
// time.Sleep(100 * time.Millisecond)
|
||||
// return either.Right[error](tailrec.Bounce[string](n - 1))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// countdown := readerioresult.TailRec(countdownStep)
|
||||
//
|
||||
// // With cancellation
|
||||
// ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
// defer cancel()
|
||||
// result := countdown(10)(ctx)() // Will be cancelled after ~500ms
|
||||
//
|
||||
// # Example: Cancellable File Processing
|
||||
//
|
||||
// type ProcessState struct {
|
||||
// files []string
|
||||
// processed []string
|
||||
// }
|
||||
//
|
||||
// processStep := func(state ProcessState) readerioresult.ReaderIOResult[tailrec.Trampoline[ProcessState, []string]] {
|
||||
// return func(ctx context.Context) ioeither.IOEither[error, tailrec.Trampoline[ProcessState, []string]] {
|
||||
// return func() either.Either[error, tailrec.Trampoline[ProcessState, []string]] {
|
||||
// if len(state.files) == 0 {
|
||||
// return either.Right[error](tailrec.Land[ProcessState](state.processed))
|
||||
// }
|
||||
//
|
||||
// file := state.files[0]
|
||||
// // Process file (this could be cancelled via context)
|
||||
// if err := processFileWithContext(ctx, file); err != nil {
|
||||
// return either.Left[tailrec.Trampoline[ProcessState, []string]](err)
|
||||
// }
|
||||
//
|
||||
// return either.Right[error](tailrec.Bounce[[]string](ProcessState{
|
||||
// files: state.files[1:],
|
||||
// processed: append(state.processed, file),
|
||||
// }))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// processFiles := readerioresult.TailRec(processStep)
|
||||
// ctx, cancel := context.WithCancel(context.Background())
|
||||
//
|
||||
// // Can be cancelled at any point during processing
|
||||
// go func() {
|
||||
// time.Sleep(2 * time.Second)
|
||||
// cancel() // Cancel after 2 seconds
|
||||
// }()
|
||||
//
|
||||
// result := processFiles(ProcessState{files: manyFiles})(ctx)()
|
||||
//
|
||||
// # Stack Safety
|
||||
//
|
||||
// The iterative implementation ensures that even deeply recursive computations
|
||||
// (thousands or millions of iterations) will not cause stack overflow, while
|
||||
// still respecting context cancellation:
|
||||
//
|
||||
// // Safe for very large inputs with cancellation support
|
||||
// largeCountdown := readerioresult.TailRec(countdownStep)
|
||||
// ctx := context.Background()
|
||||
// result := largeCountdown(1000000)(ctx)() // Safe, no stack overflow
|
||||
//
|
||||
// # Performance Considerations
|
||||
//
|
||||
// - Each iteration includes context cancellation checking overhead
|
||||
// - Context checking happens before each recursive step
|
||||
// - For performance-critical code, consider the cancellation checking cost
|
||||
// - The [WithContext] wrapper adds minimal overhead for cancellation safety
|
||||
//
|
||||
// # See Also
|
||||
//
|
||||
// - [readerioresult.TailRec]: Base tail recursion without automatic context checking
|
||||
// - [WithContext]: Context cancellation wrapper used internally
|
||||
// - [Chain]: For sequencing ReaderIOResult computations
|
||||
// - [Ask]: For accessing the context
|
||||
// - [Left]/[Right]: For creating error/success values
|
||||
//
|
||||
//go:inline
|
||||
func TailRec[A, B any](f Kleisli[A, Trampoline[A, B]]) Kleisli[A, B] {
|
||||
return RIOR.TailRec(F.Flow2(f, WithContext))
|
||||
}
|
||||
434
v2/context/readerioresult/rec_test.go
Normal file
434
v2/context/readerioresult/rec_test.go
Normal file
@@ -0,0 +1,434 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package readerioresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
A "github.com/IBM/fp-go/v2/array"
|
||||
E "github.com/IBM/fp-go/v2/either"
|
||||
"github.com/IBM/fp-go/v2/tailrec"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTailRec_BasicRecursion(t *testing.T) {
|
||||
// Test basic countdown recursion
|
||||
countdownStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
|
||||
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
|
||||
return func() Either[Trampoline[int, string]] {
|
||||
if n <= 0 {
|
||||
return E.Right[error](tailrec.Land[int]("Done!"))
|
||||
}
|
||||
return E.Right[error](tailrec.Bounce[string](n - 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
countdown := TailRec(countdownStep)
|
||||
result := countdown(5)(context.Background())()
|
||||
|
||||
assert.Equal(t, E.Of[error]("Done!"), result)
|
||||
}
|
||||
|
||||
func TestTailRec_FactorialRecursion(t *testing.T) {
|
||||
// Test factorial computation using tail recursion
|
||||
type FactorialState struct {
|
||||
n int
|
||||
acc int
|
||||
}
|
||||
|
||||
factorialStep := func(state FactorialState) ReaderIOResult[Trampoline[FactorialState, int]] {
|
||||
return func(ctx context.Context) IOEither[Trampoline[FactorialState, int]] {
|
||||
return func() Either[Trampoline[FactorialState, int]] {
|
||||
if state.n <= 1 {
|
||||
return E.Right[error](tailrec.Land[FactorialState](state.acc))
|
||||
}
|
||||
return E.Right[error](tailrec.Bounce[int](FactorialState{
|
||||
n: state.n - 1,
|
||||
acc: state.acc * state.n,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
factorial := TailRec(factorialStep)
|
||||
result := factorial(FactorialState{n: 5, acc: 1})(context.Background())()
|
||||
|
||||
assert.Equal(t, E.Of[error](120), result) // 5! = 120
|
||||
}
|
||||
|
||||
func TestTailRec_ErrorHandling(t *testing.T) {
|
||||
// Test that errors are properly propagated
|
||||
testErr := errors.New("computation error")
|
||||
|
||||
errorStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
|
||||
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
|
||||
return func() Either[Trampoline[int, string]] {
|
||||
if n == 3 {
|
||||
return E.Left[Trampoline[int, string]](testErr)
|
||||
}
|
||||
if n <= 0 {
|
||||
return E.Right[error](tailrec.Land[int]("Done!"))
|
||||
}
|
||||
return E.Right[error](tailrec.Bounce[string](n - 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errorRecursion := TailRec(errorStep)
|
||||
result := errorRecursion(5)(context.Background())()
|
||||
|
||||
assert.True(t, E.IsLeft(result))
|
||||
err := E.ToError(result)
|
||||
assert.Equal(t, testErr, err)
|
||||
}
|
||||
|
||||
func TestTailRec_ContextCancellation(t *testing.T) {
|
||||
// Test that recursion gets cancelled early when context is canceled
|
||||
var iterationCount int32
|
||||
|
||||
slowStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
|
||||
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
|
||||
return func() Either[Trampoline[int, string]] {
|
||||
atomic.AddInt32(&iterationCount, 1)
|
||||
|
||||
// Simulate some work
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if n <= 0 {
|
||||
return E.Right[error](tailrec.Land[int]("Done!"))
|
||||
}
|
||||
return E.Right[error](tailrec.Bounce[string](n - 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slowRecursion := TailRec(slowStep)
|
||||
|
||||
// Create a context that will be cancelled after 100ms
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
result := slowRecursion(10)(ctx)()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Should be cancelled and return an error
|
||||
assert.True(t, E.IsLeft(result))
|
||||
|
||||
// Should complete quickly due to cancellation (much less than 10 * 50ms = 500ms)
|
||||
assert.Less(t, elapsed, 200*time.Millisecond)
|
||||
|
||||
// Should have executed only a few iterations before cancellation
|
||||
iterations := atomic.LoadInt32(&iterationCount)
|
||||
assert.Less(t, iterations, int32(5), "Should have been cancelled before completing all iterations")
|
||||
}
|
||||
|
||||
func TestTailRec_ImmediateCancellation(t *testing.T) {
|
||||
// Test with an already cancelled context
|
||||
countdownStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
|
||||
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
|
||||
return func() Either[Trampoline[int, string]] {
|
||||
if n <= 0 {
|
||||
return E.Right[error](tailrec.Land[int]("Done!"))
|
||||
}
|
||||
return E.Right[error](tailrec.Bounce[string](n - 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
countdown := TailRec(countdownStep)
|
||||
|
||||
// Create an already cancelled context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
result := countdown(5)(ctx)()
|
||||
|
||||
// Should immediately return a cancellation error
|
||||
assert.True(t, E.IsLeft(result))
|
||||
err := E.ToError(result)
|
||||
assert.Equal(t, context.Canceled, err)
|
||||
}
|
||||
|
||||
func TestTailRec_StackSafety(t *testing.T) {
|
||||
// Test that deep recursion doesn't cause stack overflow
|
||||
const largeN = 10000
|
||||
|
||||
countdownStep := func(n int) ReaderIOResult[Trampoline[int, int]] {
|
||||
return func(ctx context.Context) IOEither[Trampoline[int, int]] {
|
||||
return func() Either[Trampoline[int, int]] {
|
||||
if n <= 0 {
|
||||
return E.Right[error](tailrec.Land[int](0))
|
||||
}
|
||||
return E.Right[error](tailrec.Bounce[int](n - 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
countdown := TailRec(countdownStep)
|
||||
result := countdown(largeN)(context.Background())()
|
||||
|
||||
assert.Equal(t, E.Of[error](0), result)
|
||||
}
|
||||
|
||||
func TestTailRec_StackSafetyWithCancellation(t *testing.T) {
|
||||
// Test stack safety with cancellation after many iterations
|
||||
const largeN = 100000
|
||||
var iterationCount int32
|
||||
|
||||
countdownStep := func(n int) ReaderIOResult[Trampoline[int, int]] {
|
||||
return func(ctx context.Context) IOEither[Trampoline[int, int]] {
|
||||
return func() Either[Trampoline[int, int]] {
|
||||
atomic.AddInt32(&iterationCount, 1)
|
||||
|
||||
// Add a small delay every 1000 iterations to make cancellation more likely
|
||||
if n%1000 == 0 {
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
|
||||
if n <= 0 {
|
||||
return E.Right[error](tailrec.Land[int](0))
|
||||
}
|
||||
return E.Right[error](tailrec.Bounce[int](n - 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
countdown := TailRec(countdownStep)
|
||||
|
||||
// Cancel after 50ms to allow some iterations but not all
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
result := countdown(largeN)(ctx)()
|
||||
|
||||
// Should be cancelled (or completed if very fast)
|
||||
// The key is that it doesn't cause a stack overflow
|
||||
iterations := atomic.LoadInt32(&iterationCount)
|
||||
assert.Greater(t, iterations, int32(0))
|
||||
|
||||
// If it was cancelled, verify it didn't complete all iterations
|
||||
if E.IsLeft(result) {
|
||||
assert.Less(t, iterations, int32(largeN))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailRec_ComplexState(t *testing.T) {
|
||||
// Test with more complex state management
|
||||
type ProcessState struct {
|
||||
items []string
|
||||
processed []string
|
||||
errors []error
|
||||
}
|
||||
|
||||
processStep := func(state ProcessState) ReaderIOResult[Trampoline[ProcessState, []string]] {
|
||||
return func(ctx context.Context) IOEither[Trampoline[ProcessState, []string]] {
|
||||
return func() Either[Trampoline[ProcessState, []string]] {
|
||||
if A.IsEmpty(state.items) {
|
||||
return E.Right[error](tailrec.Land[ProcessState](state.processed))
|
||||
}
|
||||
|
||||
item := state.items[0]
|
||||
|
||||
// Simulate processing that might fail for certain items
|
||||
if item == "error-item" {
|
||||
return E.Left[Trampoline[ProcessState, []string]](
|
||||
fmt.Errorf("failed to process item: %s", item))
|
||||
}
|
||||
|
||||
return E.Right[error](tailrec.Bounce[[]string](ProcessState{
|
||||
items: state.items[1:],
|
||||
processed: append(state.processed, item),
|
||||
errors: state.errors,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processItems := TailRec(processStep)
|
||||
|
||||
t.Run("successful processing", func(t *testing.T) {
|
||||
initialState := ProcessState{
|
||||
items: []string{"item1", "item2", "item3"},
|
||||
processed: []string{},
|
||||
errors: []error{},
|
||||
}
|
||||
|
||||
result := processItems(initialState)(context.Background())()
|
||||
|
||||
assert.Equal(t, E.Of[error]([]string{"item1", "item2", "item3"}), result)
|
||||
})
|
||||
|
||||
t.Run("processing with error", func(t *testing.T) {
|
||||
initialState := ProcessState{
|
||||
items: []string{"item1", "error-item", "item3"},
|
||||
processed: []string{},
|
||||
errors: []error{},
|
||||
}
|
||||
|
||||
result := processItems(initialState)(context.Background())()
|
||||
|
||||
assert.True(t, E.IsLeft(result))
|
||||
err := E.ToError(result)
|
||||
assert.Contains(t, err.Error(), "failed to process item: error-item")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTailRec_CancellationDuringProcessing(t *testing.T) {
|
||||
// Test cancellation during a realistic processing scenario
|
||||
type FileProcessState struct {
|
||||
files []string
|
||||
processed int
|
||||
}
|
||||
|
||||
var processedCount int32
|
||||
|
||||
processFileStep := func(state FileProcessState) ReaderIOResult[Trampoline[FileProcessState, int]] {
|
||||
return func(ctx context.Context) IOEither[Trampoline[FileProcessState, int]] {
|
||||
return func() Either[Trampoline[FileProcessState, int]] {
|
||||
if A.IsEmpty(state.files) {
|
||||
return E.Right[error](tailrec.Land[FileProcessState](state.processed))
|
||||
}
|
||||
|
||||
// Simulate file processing time
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
atomic.AddInt32(&processedCount, 1)
|
||||
|
||||
return E.Right[error](tailrec.Bounce[int](FileProcessState{
|
||||
files: state.files[1:],
|
||||
processed: state.processed + 1,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processFiles := TailRec(processFileStep)
|
||||
|
||||
// Create many files to process
|
||||
files := make([]string, 20)
|
||||
for i := range files {
|
||||
files[i] = fmt.Sprintf("file%d.txt", i)
|
||||
}
|
||||
|
||||
initialState := FileProcessState{
|
||||
files: files,
|
||||
processed: 0,
|
||||
}
|
||||
|
||||
// Cancel after 100ms (should allow ~5 files to be processed)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
result := processFiles(initialState)(ctx)()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Should be cancelled
|
||||
assert.True(t, E.IsLeft(result))
|
||||
|
||||
// Should complete quickly due to cancellation
|
||||
assert.Less(t, elapsed, 150*time.Millisecond)
|
||||
|
||||
// Should have processed some but not all files
|
||||
processed := atomic.LoadInt32(&processedCount)
|
||||
assert.Greater(t, processed, int32(0))
|
||||
assert.Less(t, processed, int32(20))
|
||||
}
|
||||
|
||||
func TestTailRec_ZeroIterations(t *testing.T) {
|
||||
// Test case where recursion terminates immediately
|
||||
immediateStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
|
||||
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
|
||||
return func() Either[Trampoline[int, string]] {
|
||||
return E.Right[error](tailrec.Land[int]("immediate"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
immediate := TailRec(immediateStep)
|
||||
result := immediate(100)(context.Background())()
|
||||
|
||||
assert.Equal(t, E.Of[error]("immediate"), result)
|
||||
}
|
||||
|
||||
func TestTailRec_ContextWithDeadline(t *testing.T) {
|
||||
// Test with context deadline
|
||||
var iterationCount int32
|
||||
|
||||
slowStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
|
||||
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
|
||||
return func() Either[Trampoline[int, string]] {
|
||||
atomic.AddInt32(&iterationCount, 1)
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
|
||||
if n <= 0 {
|
||||
return E.Right[error](tailrec.Land[int]("Done!"))
|
||||
}
|
||||
return E.Right[error](tailrec.Bounce[string](n - 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slowRecursion := TailRec(slowStep)
|
||||
|
||||
// Set deadline 80ms from now
|
||||
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(80*time.Millisecond))
|
||||
defer cancel()
|
||||
|
||||
result := slowRecursion(10)(ctx)()
|
||||
|
||||
// Should be cancelled due to deadline
|
||||
assert.True(t, E.IsLeft(result))
|
||||
|
||||
// Should have executed only a few iterations
|
||||
iterations := atomic.LoadInt32(&iterationCount)
|
||||
assert.Greater(t, iterations, int32(0))
|
||||
assert.Less(t, iterations, int32(5))
|
||||
}
|
||||
|
||||
func TestTailRec_ContextWithValue(t *testing.T) {
|
||||
// Test that context values are preserved through recursion
|
||||
type contextKey string
|
||||
const testKey contextKey = "test"
|
||||
|
||||
valueStep := func(n int) ReaderIOResult[Trampoline[int, string]] {
|
||||
return func(ctx context.Context) IOEither[Trampoline[int, string]] {
|
||||
return func() Either[Trampoline[int, string]] {
|
||||
value := ctx.Value(testKey)
|
||||
require.NotNil(t, value)
|
||||
assert.Equal(t, "test-value", value.(string))
|
||||
|
||||
if n <= 0 {
|
||||
return E.Right[error](tailrec.Land[int]("Done!"))
|
||||
}
|
||||
return E.Right[error](tailrec.Bounce[string](n - 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
valueRecursion := TailRec(valueStep)
|
||||
ctx := context.WithValue(context.Background(), testKey, "test-value")
|
||||
result := valueRecursion(3)(ctx)()
|
||||
|
||||
assert.Equal(t, E.Of[error]("Done!"), result)
|
||||
}
|
||||
181
v2/context/readerioresult/retry.go
Normal file
181
v2/context/readerioresult/retry.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache LicensVersion 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package readerioresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
RIO "github.com/IBM/fp-go/v2/context/readerio"
|
||||
R "github.com/IBM/fp-go/v2/retry"
|
||||
RG "github.com/IBM/fp-go/v2/retry/generic"
|
||||
)
|
||||
|
||||
// Retrying retries a ReaderIOResult computation according to a retry policy with context awareness.
|
||||
//
|
||||
// This function implements a retry mechanism for operations that depend on a [context.Context],
|
||||
// perform side effects (IO), and can fail (Result). It respects context cancellation, meaning
|
||||
// that if the context is cancelled during retry delays, the operation will stop immediately
|
||||
// and return the cancellation error.
|
||||
//
|
||||
// The retry loop will continue until one of the following occurs:
|
||||
// - The action succeeds and the check function returns false (no retry needed)
|
||||
// - The retry policy returns None (retry limit reached)
|
||||
// - The check function returns false (indicating success or a non-retryable failure)
|
||||
// - The context is cancelled (returns context.Canceled or context.DeadlineExceeded)
|
||||
//
|
||||
// Parameters:
|
||||
//
|
||||
// - policy: A RetryPolicy that determines when and how long to wait between retries.
|
||||
// The policy receives a RetryStatus on each iteration and returns an optional delay.
|
||||
// If it returns None, retrying stops. Common policies include LimitRetries,
|
||||
// ExponentialBackoff, and CapDelay from the retry package.
|
||||
//
|
||||
// - action: A Kleisli arrow that takes a RetryStatus and returns a ReaderIOResult[A].
|
||||
// This function is called on each retry attempt and receives information about the
|
||||
// current retry state (iteration number, cumulative delay, etc.). The action depends
|
||||
// on a context.Context and produces a Result[A]. The context passed to the action
|
||||
// will be the same context used for retry delays, so cancellation is properly propagated.
|
||||
//
|
||||
// - check: A predicate function that examines the Result[A] and returns true if the
|
||||
// operation should be retried, or false if it should stop. This allows you to
|
||||
// distinguish between retryable failures (e.g., network timeouts) and permanent
|
||||
// failures (e.g., invalid input). Note that context cancellation errors will
|
||||
// automatically stop retrying regardless of this function's return value.
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// A ReaderIOResult[A] that, when executed with a context, will perform the retry
|
||||
// logic with context cancellation support and return the final result.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of the success value
|
||||
//
|
||||
// Context Cancellation:
|
||||
//
|
||||
// The retry mechanism respects context cancellation in two ways:
|
||||
// 1. During retry delays: If the context is cancelled while waiting between retries,
|
||||
// the operation stops immediately and returns the context error.
|
||||
// 2. During action execution: If the action itself checks the context and returns
|
||||
// an error due to cancellation, the retry loop will stop (assuming the check
|
||||
// function doesn't force a retry on context errors).
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Create a retry policy: exponential backoff with a cap, limited to 5 retries
|
||||
// policy := M.Concat(
|
||||
// retry.LimitRetries(5),
|
||||
// retry.CapDelay(10*time.Second, retry.ExponentialBackoff(100*time.Millisecond)),
|
||||
// )(retry.Monoid)
|
||||
//
|
||||
// // Action that fetches data, with retry status information
|
||||
// fetchData := func(status retry.RetryStatus) ReaderIOResult[string] {
|
||||
// return func(ctx context.Context) IOResult[string] {
|
||||
// return func() Result[string] {
|
||||
// // Check if context is cancelled
|
||||
// if ctx.Err() != nil {
|
||||
// return result.Left[string](ctx.Err())
|
||||
// }
|
||||
// // Simulate an HTTP request that might fail
|
||||
// if status.IterNumber < 3 {
|
||||
// return result.Left[string](fmt.Errorf("temporary error"))
|
||||
// }
|
||||
// return result.Of("success")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Check function: retry on any error except context cancellation
|
||||
// shouldRetry := func(r Result[string]) bool {
|
||||
// return result.IsLeft(r) && !errors.Is(result.GetLeft(r), context.Canceled)
|
||||
// }
|
||||
//
|
||||
// // Create the retrying computation
|
||||
// retryingFetch := Retrying(policy, fetchData, shouldRetry)
|
||||
//
|
||||
// // Execute with a cancellable context
|
||||
// ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
// defer cancel()
|
||||
// ioResult := retryingFetch(ctx)
|
||||
// finalResult := ioResult()
|
||||
//
|
||||
// See also:
|
||||
// - retry.RetryPolicy for available retry policies
|
||||
// - retry.RetryStatus for information passed to the action
|
||||
// - context.Context for context cancellation semantics
|
||||
//
|
||||
//go:inline
|
||||
func Retrying[A any](
|
||||
policy R.RetryPolicy,
|
||||
action Kleisli[R.RetryStatus, A],
|
||||
check Predicate[Result[A]],
|
||||
) ReaderIOResult[A] {
|
||||
|
||||
// delayWithCancel implements a context-aware delay mechanism for retry operations.
|
||||
// It creates a timeout context that will be cancelled when either:
|
||||
// 1. The delay duration expires (normal case), or
|
||||
// 2. The parent context is cancelled (early termination)
|
||||
//
|
||||
// The function waits on timeoutCtx.Done(), which will be signaled in either case:
|
||||
// - If the delay expires, timeoutCtx is cancelled by the timeout
|
||||
// - If the parent ctx is cancelled, timeoutCtx inherits the cancellation
|
||||
//
|
||||
// After the wait completes, we dispatch to the next action by calling ri(ctx)().
|
||||
// This works correctly because the action is wrapped in WithContextK, which handles
|
||||
// context cancellation by checking ctx.Err() and returning an appropriate error
|
||||
// (context.Canceled or context.DeadlineExceeded) when the context is cancelled.
|
||||
//
|
||||
// This design ensures that:
|
||||
// - Retry delays respect context cancellation and terminate immediately
|
||||
// - The cancellation error propagates correctly through the retry chain
|
||||
// - No unnecessary delays occur when the context is already cancelled
|
||||
delayWithCancel := func(delay time.Duration) RIO.Operator[R.RetryStatus, R.RetryStatus] {
|
||||
return func(ri ReaderIO[R.RetryStatus]) ReaderIO[R.RetryStatus] {
|
||||
return func(ctx context.Context) IO[R.RetryStatus] {
|
||||
return func() R.RetryStatus {
|
||||
// Create a timeout context that will be cancelled when either:
|
||||
// - The delay duration expires, or
|
||||
// - The parent context is cancelled
|
||||
timeoutCtx, cancelTimeout := context.WithTimeout(ctx, delay)
|
||||
defer cancelTimeout()
|
||||
|
||||
// Wait for either the timeout or parent context cancellation
|
||||
<-timeoutCtx.Done()
|
||||
|
||||
// Dispatch to the next action with the original context.
|
||||
// WithContextK will handle context cancellation correctly.
|
||||
return ri(ctx)()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// get an implementation for the types
|
||||
return RG.Retrying(
|
||||
RIO.Chain[Result[A], Trampoline[R.RetryStatus, Result[A]]],
|
||||
RIO.Map[R.RetryStatus, Trampoline[R.RetryStatus, Result[A]]],
|
||||
RIO.Of[Trampoline[R.RetryStatus, Result[A]]],
|
||||
RIO.Of[R.RetryStatus],
|
||||
delayWithCancel,
|
||||
|
||||
RIO.TailRec,
|
||||
|
||||
policy,
|
||||
WithContextK(action),
|
||||
check,
|
||||
)
|
||||
|
||||
}
|
||||
511
v2/context/readerioresult/retry_test.go
Normal file
511
v2/context/readerioresult/retry_test.go
Normal file
@@ -0,0 +1,511 @@
|
||||
// Copyright (c) 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package readerioresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
R "github.com/IBM/fp-go/v2/retry"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Helper function to create a test retry policy
|
||||
func testRetryPolicy() R.RetryPolicy {
|
||||
return R.Monoid.Concat(
|
||||
R.LimitRetries(5),
|
||||
R.CapDelay(1*time.Second, R.ExponentialBackoff(10*time.Millisecond)),
|
||||
)
|
||||
}
|
||||
|
||||
// TestRetrying_SuccessOnFirstAttempt tests that Retrying succeeds immediately
|
||||
// when the action succeeds on the first attempt.
|
||||
func TestRetrying_SuccessOnFirstAttempt(t *testing.T) {
|
||||
policy := testRetryPolicy()
|
||||
|
||||
action := func(status R.RetryStatus) ReaderIOResult[string] {
|
||||
return func(ctx context.Context) IOResult[string] {
|
||||
return func() Result[string] {
|
||||
return result.Of("success")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check := func(r Result[string]) bool {
|
||||
return result.IsLeft(r)
|
||||
}
|
||||
|
||||
retrying := Retrying(policy, action, check)
|
||||
ctx := t.Context()
|
||||
|
||||
res := retrying(ctx)()
|
||||
|
||||
assert.Equal(t, result.Of("success"), res)
|
||||
}
|
||||
|
||||
// TestRetrying_SuccessAfterRetries tests that Retrying eventually succeeds
|
||||
// after a few failed attempts.
|
||||
func TestRetrying_SuccessAfterRetries(t *testing.T) {
|
||||
policy := testRetryPolicy()
|
||||
|
||||
action := func(status R.RetryStatus) ReaderIOResult[string] {
|
||||
return func(ctx context.Context) IOResult[string] {
|
||||
return func() Result[string] {
|
||||
// Fail on first 3 attempts, succeed on 4th
|
||||
if status.IterNumber < 3 {
|
||||
return result.Left[string](fmt.Errorf("attempt %d failed", status.IterNumber))
|
||||
}
|
||||
return result.Of(fmt.Sprintf("success on attempt %d", status.IterNumber))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check := func(r Result[string]) bool {
|
||||
return result.IsLeft(r)
|
||||
}
|
||||
|
||||
retrying := Retrying(policy, action, check)
|
||||
ctx := t.Context()
|
||||
|
||||
res := retrying(ctx)()
|
||||
|
||||
assert.Equal(t, result.Of("success on attempt 3"), res)
|
||||
}
|
||||
|
||||
// TestRetrying_ExhaustsRetries tests that Retrying stops after the retry limit
|
||||
// is reached and returns the last error.
|
||||
func TestRetrying_ExhaustsRetries(t *testing.T) {
|
||||
policy := R.LimitRetries(3)
|
||||
|
||||
action := func(status R.RetryStatus) ReaderIOResult[string] {
|
||||
return func(ctx context.Context) IOResult[string] {
|
||||
return func() Result[string] {
|
||||
return result.Left[string](fmt.Errorf("attempt %d failed", status.IterNumber))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check := func(r Result[string]) bool {
|
||||
return result.IsLeft(r)
|
||||
}
|
||||
|
||||
retrying := Retrying(policy, action, check)
|
||||
ctx := t.Context()
|
||||
|
||||
res := retrying(ctx)()
|
||||
|
||||
assert.True(t, result.IsLeft(res))
|
||||
assert.Equal(t, result.Left[string](fmt.Errorf("attempt 3 failed")), res)
|
||||
}
|
||||
|
||||
// TestRetrying_ActionChecksContextCancellation tests that actions can check
|
||||
// the context and return early if it's cancelled.
|
||||
func TestRetrying_ActionChecksContextCancellation(t *testing.T) {
|
||||
policy := R.LimitRetries(10)
|
||||
|
||||
attemptCount := 0
|
||||
|
||||
action := func(status R.RetryStatus) ReaderIOResult[string] {
|
||||
return func(ctx context.Context) IOResult[string] {
|
||||
return func() Result[string] {
|
||||
attemptCount++
|
||||
|
||||
// Check context at the start of the action
|
||||
if ctx.Err() != nil {
|
||||
return result.Left[string](ctx.Err())
|
||||
}
|
||||
|
||||
// Simulate work that might take time
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Check context again after work
|
||||
if ctx.Err() != nil {
|
||||
return result.Left[string](ctx.Err())
|
||||
}
|
||||
|
||||
// Always fail to trigger retries
|
||||
return result.Left[string](fmt.Errorf("attempt %d failed", status.IterNumber))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check := func(r Result[string]) bool {
|
||||
// Don't retry on context errors
|
||||
val, err := result.Unwrap(r)
|
||||
_ = val
|
||||
if err != nil && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) {
|
||||
return false
|
||||
}
|
||||
return result.IsLeft(r)
|
||||
}
|
||||
|
||||
retrying := Retrying(policy, action, check)
|
||||
|
||||
// Create a context that we'll cancel after a short time
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
|
||||
// Start the retry operation in a goroutine
|
||||
resultChan := make(chan Result[string], 1)
|
||||
go func() {
|
||||
res := retrying(ctx)()
|
||||
resultChan <- res
|
||||
}()
|
||||
|
||||
// Cancel the context after allowing a couple attempts
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
// Wait for the result
|
||||
res := <-resultChan
|
||||
|
||||
// Should have stopped due to context cancellation
|
||||
assert.True(t, result.IsLeft(res))
|
||||
|
||||
// Should have stopped early (not all 10 attempts)
|
||||
assert.Less(t, attemptCount, 10, "Should stop retrying when action detects context cancellation")
|
||||
|
||||
// The error should be related to context cancellation or an early attempt
|
||||
val, err := result.Unwrap(res)
|
||||
_ = val
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// TestRetrying_ContextCancelledBeforeStart tests that if the context is already
|
||||
// cancelled before starting, the operation fails immediately.
|
||||
func TestRetrying_ContextCancelledBeforeStart(t *testing.T) {
|
||||
policy := testRetryPolicy()
|
||||
|
||||
attemptCount := 0
|
||||
|
||||
action := func(status R.RetryStatus) ReaderIOResult[string] {
|
||||
return func(ctx context.Context) IOResult[string] {
|
||||
return func() Result[string] {
|
||||
attemptCount++
|
||||
// Check context before doing work
|
||||
if ctx.Err() != nil {
|
||||
return result.Left[string](ctx.Err())
|
||||
}
|
||||
return result.Left[string](fmt.Errorf("attempt %d failed", status.IterNumber))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check := func(r Result[string]) bool {
|
||||
// Don't retry on context errors
|
||||
val, err := result.Unwrap(r)
|
||||
_ = val
|
||||
if err != nil && errors.Is(err, context.Canceled) {
|
||||
return false
|
||||
}
|
||||
return result.IsLeft(r)
|
||||
}
|
||||
|
||||
retrying := Retrying(policy, action, check)
|
||||
|
||||
// Create an already-cancelled context
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
res := retrying(ctx)()
|
||||
|
||||
assert.True(t, result.IsLeft(res))
|
||||
val, err := result.Unwrap(res)
|
||||
_ = val
|
||||
assert.True(t, errors.Is(err, context.Canceled))
|
||||
|
||||
// Should have attempted at most once
|
||||
assert.LessOrEqual(t, attemptCount, 1)
|
||||
}
|
||||
|
||||
// TestRetrying_ContextTimeoutInAction tests that actions respect context deadlines.
|
||||
func TestRetrying_ContextTimeoutInAction(t *testing.T) {
|
||||
policy := R.LimitRetries(10)
|
||||
|
||||
attemptCount := 0
|
||||
|
||||
action := func(status R.RetryStatus) ReaderIOResult[string] {
|
||||
return func(ctx context.Context) IOResult[string] {
|
||||
return func() Result[string] {
|
||||
attemptCount++
|
||||
|
||||
// Check context before doing work
|
||||
if ctx.Err() != nil {
|
||||
return result.Left[string](ctx.Err())
|
||||
}
|
||||
|
||||
// Simulate some work
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Check context after work
|
||||
if ctx.Err() != nil {
|
||||
return result.Left[string](ctx.Err())
|
||||
}
|
||||
|
||||
// Always fail to trigger retries
|
||||
return result.Left[string](fmt.Errorf("attempt %d failed", status.IterNumber))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check := func(r Result[string]) bool {
|
||||
// Don't retry on context errors
|
||||
val, err := result.Unwrap(r)
|
||||
_ = val
|
||||
if err != nil && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) {
|
||||
return false
|
||||
}
|
||||
return result.IsLeft(r)
|
||||
}
|
||||
|
||||
retrying := Retrying(policy, action, check)
|
||||
|
||||
// Create a context with a short timeout
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
startTime := time.Now()
|
||||
res := retrying(ctx)()
|
||||
elapsed := time.Since(startTime)
|
||||
|
||||
assert.True(t, result.IsLeft(res))
|
||||
|
||||
// Should have stopped before completing all 10 retries
|
||||
assert.Less(t, attemptCount, 10, "Should stop retrying when action detects context timeout")
|
||||
|
||||
// Should have stopped around the timeout duration
|
||||
assert.Less(t, elapsed, 500*time.Millisecond, "Should stop soon after timeout")
|
||||
}
|
||||
|
||||
// TestRetrying_CheckFunctionStopsRetry tests that the check function can
|
||||
// stop retrying even when errors occur.
|
||||
func TestRetrying_CheckFunctionStopsRetry(t *testing.T) {
|
||||
policy := testRetryPolicy()
|
||||
|
||||
action := func(status R.RetryStatus) ReaderIOResult[string] {
|
||||
return func(ctx context.Context) IOResult[string] {
|
||||
return func() Result[string] {
|
||||
if status.IterNumber == 0 {
|
||||
return result.Left[string](fmt.Errorf("retryable error"))
|
||||
}
|
||||
return result.Left[string](fmt.Errorf("permanent error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only retry on "retryable error"
|
||||
check := func(r Result[string]) bool {
|
||||
return result.IsLeft(r) && result.Fold(
|
||||
func(err error) bool { return err.Error() == "retryable error" },
|
||||
func(string) bool { return false },
|
||||
)(r)
|
||||
}
|
||||
|
||||
retrying := Retrying(policy, action, check)
|
||||
ctx := t.Context()
|
||||
|
||||
res := retrying(ctx)()
|
||||
|
||||
assert.Equal(t, result.Left[string](fmt.Errorf("permanent error")), res)
|
||||
}
|
||||
|
||||
// TestRetrying_ExponentialBackoff tests that exponential backoff is applied.
|
||||
func TestRetrying_ExponentialBackoff(t *testing.T) {
|
||||
// Use a policy with measurable delays
|
||||
policy := R.Monoid.Concat(
|
||||
R.LimitRetries(3),
|
||||
R.ExponentialBackoff(50*time.Millisecond),
|
||||
)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
action := func(status R.RetryStatus) ReaderIOResult[string] {
|
||||
return func(ctx context.Context) IOResult[string] {
|
||||
return func() Result[string] {
|
||||
if status.IterNumber < 2 {
|
||||
return result.Left[string](fmt.Errorf("retry"))
|
||||
}
|
||||
return result.Of("success")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check := func(r Result[string]) bool {
|
||||
return result.IsLeft(r)
|
||||
}
|
||||
|
||||
retrying := Retrying(policy, action, check)
|
||||
ctx := t.Context()
|
||||
|
||||
res := retrying(ctx)()
|
||||
elapsed := time.Since(startTime)
|
||||
|
||||
assert.Equal(t, result.Of("success"), res)
|
||||
// With exponential backoff starting at 50ms:
|
||||
// Iteration 0: no delay
|
||||
// Iteration 1: 50ms delay
|
||||
// Iteration 2: 100ms delay
|
||||
// Total should be at least 150ms
|
||||
assert.GreaterOrEqual(t, elapsed, 150*time.Millisecond)
|
||||
}
|
||||
|
||||
// TestRetrying_ContextValuePropagation tests that context values are properly
|
||||
// propagated through the retry mechanism.
|
||||
func TestRetrying_ContextValuePropagation(t *testing.T) {
|
||||
policy := R.LimitRetries(2)
|
||||
|
||||
type contextKey string
|
||||
const requestIDKey contextKey = "requestID"
|
||||
|
||||
action := func(status R.RetryStatus) ReaderIOResult[string] {
|
||||
return func(ctx context.Context) IOResult[string] {
|
||||
return func() Result[string] {
|
||||
// Extract value from context
|
||||
requestID, ok := ctx.Value(requestIDKey).(string)
|
||||
if !ok {
|
||||
return result.Left[string](fmt.Errorf("missing request ID"))
|
||||
}
|
||||
|
||||
if status.IterNumber < 1 {
|
||||
return result.Left[string](fmt.Errorf("retry needed"))
|
||||
}
|
||||
|
||||
return result.Of(fmt.Sprintf("processed request %s", requestID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check := func(r Result[string]) bool {
|
||||
return result.IsLeft(r)
|
||||
}
|
||||
|
||||
retrying := Retrying(policy, action, check)
|
||||
|
||||
// Create context with a value
|
||||
ctx := context.WithValue(t.Context(), requestIDKey, "12345")
|
||||
|
||||
res := retrying(ctx)()
|
||||
|
||||
assert.Equal(t, result.Of("processed request 12345"), res)
|
||||
}
|
||||
|
||||
// TestRetrying_RetryStatusProgression tests that the RetryStatus is properly
|
||||
// updated on each iteration.
|
||||
func TestRetrying_RetryStatusProgression(t *testing.T) {
|
||||
policy := testRetryPolicy()
|
||||
|
||||
var iterations []uint
|
||||
|
||||
action := func(status R.RetryStatus) ReaderIOResult[int] {
|
||||
return func(ctx context.Context) IOResult[int] {
|
||||
return func() Result[int] {
|
||||
iterations = append(iterations, status.IterNumber)
|
||||
if status.IterNumber < 3 {
|
||||
return result.Left[int](fmt.Errorf("retry"))
|
||||
}
|
||||
return result.Of(int(status.IterNumber))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check := func(r Result[int]) bool {
|
||||
return result.IsLeft(r)
|
||||
}
|
||||
|
||||
retrying := Retrying(policy, action, check)
|
||||
ctx := t.Context()
|
||||
|
||||
res := retrying(ctx)()
|
||||
|
||||
assert.Equal(t, result.Of(3), res)
|
||||
// Should have attempted iterations 0, 1, 2, 3
|
||||
assert.Equal(t, []uint{0, 1, 2, 3}, iterations)
|
||||
}
|
||||
|
||||
// TestRetrying_ContextCancelledDuringDelay tests that the retry operation
|
||||
// stops immediately when the context is cancelled during a retry delay,
|
||||
// even if there are still retries remaining according to the policy.
|
||||
func TestRetrying_ContextCancelledDuringDelay(t *testing.T) {
|
||||
// Use a policy with significant delays to ensure we can cancel during the delay
|
||||
policy := R.Monoid.Concat(
|
||||
R.LimitRetries(10),
|
||||
R.ConstantDelay(200*time.Millisecond),
|
||||
)
|
||||
|
||||
attemptCount := 0
|
||||
|
||||
action := func(status R.RetryStatus) ReaderIOResult[string] {
|
||||
return func(ctx context.Context) IOResult[string] {
|
||||
return func() Result[string] {
|
||||
attemptCount++
|
||||
// Always fail to trigger retries
|
||||
return result.Left[string](fmt.Errorf("attempt %d failed", status.IterNumber))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always retry on errors (don't check for context cancellation in check function)
|
||||
check := func(r Result[string]) bool {
|
||||
return result.IsLeft(r)
|
||||
}
|
||||
|
||||
retrying := Retrying(policy, action, check)
|
||||
|
||||
// Create a context that we'll cancel during the retry delay
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
|
||||
// Start the retry operation in a goroutine
|
||||
resultChan := make(chan Result[string], 1)
|
||||
startTime := time.Now()
|
||||
go func() {
|
||||
res := retrying(ctx)()
|
||||
resultChan <- res
|
||||
}()
|
||||
|
||||
// Wait for the first attempt to complete and the delay to start
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Cancel the context during the retry delay
|
||||
cancel()
|
||||
|
||||
// Wait for the result
|
||||
res := <-resultChan
|
||||
elapsed := time.Since(startTime)
|
||||
|
||||
// Should have stopped due to context cancellation
|
||||
assert.True(t, result.IsLeft(res))
|
||||
|
||||
// Should have attempted only once or twice (not all 10 attempts)
|
||||
// because the context was cancelled during the delay
|
||||
assert.LessOrEqual(t, attemptCount, 2, "Should stop retrying when context is cancelled during delay")
|
||||
|
||||
// Should have stopped quickly after cancellation, not waiting for all delays
|
||||
// With 10 retries and 200ms delays, it would take ~2 seconds without cancellation
|
||||
// With cancellation during first delay, it should complete in well under 500ms
|
||||
assert.Less(t, elapsed, 500*time.Millisecond, "Should stop immediately when context is cancelled during delay")
|
||||
|
||||
// When context is cancelled during the delay, the retry mechanism
|
||||
// detects the cancellation and returns a context error
|
||||
val, err := result.Unwrap(res)
|
||||
_ = val
|
||||
assert.Error(t, err)
|
||||
// The error should be a context cancellation error since cancellation
|
||||
// happened during the delay between retries
|
||||
assert.True(t, errors.Is(err, context.Canceled), "Should return context.Canceled when cancelled during delay")
|
||||
}
|
||||
@@ -18,6 +18,7 @@ package readerioresult
|
||||
import (
|
||||
"github.com/IBM/fp-go/v2/array"
|
||||
"github.com/IBM/fp-go/v2/function"
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/internal/record"
|
||||
)
|
||||
|
||||
@@ -34,7 +35,7 @@ func TraverseArray[A, B any](f Kleisli[A, B]) Kleisli[[]A, []B] {
|
||||
Map[[]B, func(B) []B],
|
||||
Ap[[]B, B],
|
||||
|
||||
f,
|
||||
F.Flow2(f, WithContext),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,7 +79,7 @@ func TraverseRecord[K comparable, A, B any](f Kleisli[A, B]) Kleisli[map[K]A, ma
|
||||
Map[map[K]B, func(B) map[K]B],
|
||||
Ap[map[K]B, B],
|
||||
|
||||
f,
|
||||
F.Flow2(f, WithContext),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -123,7 +124,7 @@ func MonadTraverseArraySeq[A, B any](as []A, f Kleisli[A, B]) ReaderIOResult[[]B
|
||||
Map[[]B, func(B) []B],
|
||||
ApSeq[[]B, B],
|
||||
as,
|
||||
f,
|
||||
F.Flow2(f, WithContext),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,7 +140,7 @@ func TraverseArraySeq[A, B any](f Kleisli[A, B]) Kleisli[[]A, []B] {
|
||||
Of[[]B],
|
||||
Map[[]B, func(B) []B],
|
||||
ApSeq[[]B, B],
|
||||
f,
|
||||
F.Flow2(f, WithContext),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -171,7 +172,7 @@ func MonadTraverseRecordSeq[K comparable, A, B any](as map[K]A, f Kleisli[A, B])
|
||||
Map[map[K]B, func(B) map[K]B],
|
||||
ApSeq[map[K]B, B],
|
||||
as,
|
||||
f,
|
||||
F.Flow2(f, WithContext),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -182,7 +183,7 @@ func TraverseRecordSeq[K comparable, A, B any](f Kleisli[A, B]) Kleisli[map[K]A,
|
||||
Map[map[K]B, func(B) map[K]B],
|
||||
ApSeq[map[K]B, B],
|
||||
|
||||
f,
|
||||
F.Flow2(f, WithContext),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -216,7 +217,7 @@ func MonadTraverseArrayPar[A, B any](as []A, f Kleisli[A, B]) ReaderIOResult[[]B
|
||||
Map[[]B, func(B) []B],
|
||||
ApPar[[]B, B],
|
||||
as,
|
||||
f,
|
||||
F.Flow2(f, WithContext),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -232,7 +233,7 @@ func TraverseArrayPar[A, B any](f Kleisli[A, B]) Kleisli[[]A, []B] {
|
||||
Of[[]B],
|
||||
Map[[]B, func(B) []B],
|
||||
ApPar[[]B, B],
|
||||
f,
|
||||
F.Flow2(f, WithContext),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -264,7 +265,7 @@ func TraverseRecordPar[K comparable, A, B any](f Kleisli[A, B]) Kleisli[map[K]A,
|
||||
Map[map[K]B, func(B) map[K]B],
|
||||
ApPar[map[K]B, B],
|
||||
|
||||
f,
|
||||
F.Flow2(f, WithContext),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -286,7 +287,7 @@ func MonadTraverseRecordPar[K comparable, A, B any](as map[K]A, f Kleisli[A, B])
|
||||
Map[map[K]B, func(B) map[K]B],
|
||||
ApPar[map[K]B, B],
|
||||
as,
|
||||
f,
|
||||
F.Flow2(f, WithContext),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,20 +18,28 @@ package readerioresult
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/IBM/fp-go/v2/consumer"
|
||||
"github.com/IBM/fp-go/v2/context/ioresult"
|
||||
"github.com/IBM/fp-go/v2/context/readerresult"
|
||||
"github.com/IBM/fp-go/v2/either"
|
||||
"github.com/IBM/fp-go/v2/endomorphism"
|
||||
"github.com/IBM/fp-go/v2/io"
|
||||
"github.com/IBM/fp-go/v2/ioeither"
|
||||
"github.com/IBM/fp-go/v2/ioref"
|
||||
"github.com/IBM/fp-go/v2/lazy"
|
||||
"github.com/IBM/fp-go/v2/optics/lens"
|
||||
"github.com/IBM/fp-go/v2/optics/prism"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
"github.com/IBM/fp-go/v2/predicate"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
"github.com/IBM/fp-go/v2/readereither"
|
||||
"github.com/IBM/fp-go/v2/readerio"
|
||||
RIOR "github.com/IBM/fp-go/v2/readerioresult"
|
||||
"github.com/IBM/fp-go/v2/readeroption"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
"github.com/IBM/fp-go/v2/state"
|
||||
"github.com/IBM/fp-go/v2/tailrec"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -129,4 +137,19 @@ type (
|
||||
ReaderOption[R, A any] = readeroption.ReaderOption[R, A]
|
||||
|
||||
Endomorphism[A any] = endomorphism.Endomorphism[A]
|
||||
|
||||
Consumer[A any] = consumer.Consumer[A]
|
||||
|
||||
Prism[S, T any] = prism.Prism[S, T]
|
||||
Lens[S, T any] = lens.Lens[S, T]
|
||||
|
||||
Trampoline[B, L any] = tailrec.Trampoline[B, L]
|
||||
|
||||
Predicate[A any] = predicate.Predicate[A]
|
||||
|
||||
Pair[A, B any] = pair.Pair[A, B]
|
||||
|
||||
IORef[A any] = ioref.IORef[A]
|
||||
|
||||
State[S, A any] = state.State[S, A]
|
||||
)
|
||||
|
||||
@@ -15,11 +15,14 @@
|
||||
|
||||
package readerresult
|
||||
|
||||
import "github.com/IBM/fp-go/v2/readereither"
|
||||
import (
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/readereither"
|
||||
)
|
||||
|
||||
// TraverseArray transforms an array
|
||||
func TraverseArray[A, B any](f Kleisli[A, B]) Kleisli[[]A, []B] {
|
||||
return readereither.TraverseArray(f)
|
||||
return readereither.TraverseArray(F.Flow2(f, WithContext))
|
||||
}
|
||||
|
||||
// TraverseArrayWithIndex transforms an array
|
||||
|
||||
@@ -17,7 +17,6 @@ package readerresult
|
||||
|
||||
import (
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
L "github.com/IBM/fp-go/v2/optics/lens"
|
||||
G "github.com/IBM/fp-go/v2/readereither/generic"
|
||||
)
|
||||
|
||||
@@ -31,16 +30,26 @@ import (
|
||||
// TenantID string
|
||||
// }
|
||||
// result := readereither.Do(State{})
|
||||
//
|
||||
//go:inline
|
||||
func Do[S any](
|
||||
empty S,
|
||||
) ReaderResult[S] {
|
||||
return G.Do[ReaderResult[S]](empty)
|
||||
}
|
||||
|
||||
// Bind attaches the result of a computation to a context [S1] to produce a context [S2].
|
||||
// Bind attaches the result of an EFFECTFUL computation to a context [S1] to produce a context [S2].
|
||||
// This enables sequential composition where each step can depend on the results of previous steps
|
||||
// and access the context.Context from the environment.
|
||||
//
|
||||
// IMPORTANT: Bind is for EFFECTFUL FUNCTIONS that depend on context.Context.
|
||||
// The function parameter takes state and returns a ReaderResult[T], which is effectful because
|
||||
// it depends on context.Context (can be cancelled, has deadlines, carries values).
|
||||
//
|
||||
// For PURE FUNCTIONS (side-effect free), use:
|
||||
// - BindResultK: For pure functions with errors (State -> (Value, error))
|
||||
// - Let: For pure functions without errors (State -> Value)
|
||||
//
|
||||
// The setter function takes the result of the computation and returns a function that
|
||||
// updates the context from S1 to S2.
|
||||
//
|
||||
@@ -78,14 +87,27 @@ func Do[S any](
|
||||
// },
|
||||
// ),
|
||||
// )
|
||||
//
|
||||
//go:inline
|
||||
func Bind[S1, S2, T any](
|
||||
setter func(T) func(S1) S2,
|
||||
f Kleisli[S1, T],
|
||||
) Kleisli[ReaderResult[S1], S2] {
|
||||
return G.Bind[ReaderResult[S1], ReaderResult[S2]](setter, f)
|
||||
return G.Bind[ReaderResult[S1], ReaderResult[S2]](setter, F.Flow2(f, WithContext))
|
||||
}
|
||||
|
||||
// Let attaches the result of a computation to a context [S1] to produce a context [S2]
|
||||
// Let attaches the result of a PURE computation to a context [S1] to produce a context [S2].
|
||||
//
|
||||
// IMPORTANT: Let is for PURE FUNCTIONS (side-effect free) that don't depend on context.Context.
|
||||
// The function parameter takes state and returns a value directly, with no errors or effects.
|
||||
//
|
||||
// For EFFECTFUL FUNCTIONS (that need context.Context), use:
|
||||
// - Bind: For effectful ReaderResult computations (State -> ReaderResult[Value])
|
||||
//
|
||||
// For PURE FUNCTIONS with error handling, use:
|
||||
// - BindResultK: For pure functions with errors (State -> (Value, error))
|
||||
//
|
||||
//go:inline
|
||||
func Let[S1, S2, T any](
|
||||
setter func(T) func(S1) S2,
|
||||
f func(S1) T,
|
||||
@@ -93,7 +115,10 @@ func Let[S1, S2, T any](
|
||||
return G.Let[ReaderResult[S1], ReaderResult[S2]](setter, f)
|
||||
}
|
||||
|
||||
// LetTo attaches the a value to a context [S1] to produce a context [S2]
|
||||
// LetTo attaches a constant value to a context [S1] to produce a context [S2].
|
||||
// This is a PURE operation (side-effect free) that simply sets a field to a constant value.
|
||||
//
|
||||
//go:inline
|
||||
func LetTo[S1, S2, T any](
|
||||
setter func(T) func(S1) S2,
|
||||
b T,
|
||||
@@ -102,15 +127,27 @@ func LetTo[S1, S2, T any](
|
||||
}
|
||||
|
||||
// BindTo initializes a new state [S1] from a value [T]
|
||||
//
|
||||
//go:inline
|
||||
func BindTo[S1, T any](
|
||||
setter func(T) S1,
|
||||
) Kleisli[ReaderResult[T], S1] {
|
||||
) Operator[T, S1] {
|
||||
return G.BindTo[ReaderResult[S1], ReaderResult[T]](setter)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func BindToP[S1, T any](
|
||||
setter Prism[S1, T],
|
||||
) Operator[T, S1] {
|
||||
return BindTo(setter.ReverseGet)
|
||||
}
|
||||
|
||||
// ApS attaches a value to a context [S1] to produce a context [S2] by considering
|
||||
// the context and the value concurrently (using Applicative rather than Monad).
|
||||
// This allows independent computations to be combined without one depending on the result of the other.
|
||||
// This allows independent EFFECTFUL computations to be combined without one depending on the result of the other.
|
||||
//
|
||||
// IMPORTANT: ApS is for EFFECTFUL FUNCTIONS that depend on context.Context.
|
||||
// The ReaderResult parameter is effectful because it depends on context.Context.
|
||||
//
|
||||
// Unlike Bind, which sequences operations, ApS can be used when operations are independent
|
||||
// and can conceptually run in parallel.
|
||||
@@ -145,6 +182,8 @@ func BindTo[S1, T any](
|
||||
// getTenantID,
|
||||
// ),
|
||||
// )
|
||||
//
|
||||
//go:inline
|
||||
func ApS[S1, S2, T any](
|
||||
setter func(T) func(S1) S2,
|
||||
fa ReaderResult[T],
|
||||
@@ -183,17 +222,24 @@ func ApS[S1, S2, T any](
|
||||
// readereither.Do(Person{Name: "Alice", Age: 25}),
|
||||
// readereither.ApSL(ageLens, getAge),
|
||||
// )
|
||||
//
|
||||
//go:inline
|
||||
func ApSL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
lens Lens[S, T],
|
||||
fa ReaderResult[T],
|
||||
) Kleisli[ReaderResult[S], S] {
|
||||
return ApS(lens.Set, fa)
|
||||
}
|
||||
|
||||
// BindL is a variant of Bind that uses a lens to focus on a specific field in the state.
|
||||
// It combines the lens-based field access with monadic composition, allowing you to:
|
||||
// It combines the lens-based field access with monadic composition for EFFECTFUL computations.
|
||||
//
|
||||
// IMPORTANT: BindL is for EFFECTFUL FUNCTIONS that depend on context.Context.
|
||||
// The function parameter returns a ReaderResult, which is effectful.
|
||||
//
|
||||
// It allows you to:
|
||||
// 1. Extract a field value using the lens
|
||||
// 2. Use that value in a computation that may fail
|
||||
// 2. Use that value in an effectful computation that may fail
|
||||
// 3. Update the field with the result
|
||||
//
|
||||
// Parameters:
|
||||
@@ -227,15 +273,20 @@ func ApSL[S, T any](
|
||||
// readereither.Of[error](Counter{Value: 42}),
|
||||
// readereither.BindL(valueLens, increment),
|
||||
// )
|
||||
//
|
||||
//go:inline
|
||||
func BindL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
lens Lens[S, T],
|
||||
f Kleisli[T, T],
|
||||
) Kleisli[ReaderResult[S], S] {
|
||||
return Bind(lens.Set, F.Flow2(lens.Get, f))
|
||||
return Bind(lens.Set, F.Flow2(lens.Get, F.Flow2(f, WithContext)))
|
||||
}
|
||||
|
||||
// LetL is a variant of Let that uses a lens to focus on a specific field in the state.
|
||||
// It applies a pure transformation to the focused field without any effects.
|
||||
// It applies a PURE transformation to the focused field without any effects.
|
||||
//
|
||||
// IMPORTANT: LetL is for PURE FUNCTIONS (side-effect free) that don't depend on context.Context.
|
||||
// The function parameter is a pure endomorphism (T -> T) with no errors or effects.
|
||||
//
|
||||
// Parameters:
|
||||
// - lens: A lens that focuses on a field of type T within state S
|
||||
@@ -262,15 +313,17 @@ func BindL[S, T any](
|
||||
// readereither.LetL(valueLens, double),
|
||||
// )
|
||||
// // result when executed will be Right(Counter{Value: 42})
|
||||
//
|
||||
//go:inline
|
||||
func LetL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
f func(T) T,
|
||||
lens Lens[S, T],
|
||||
f Endomorphism[T],
|
||||
) Kleisli[ReaderResult[S], S] {
|
||||
return Let(lens.Set, F.Flow2(lens.Get, f))
|
||||
}
|
||||
|
||||
// LetToL is a variant of LetTo that uses a lens to focus on a specific field in the state.
|
||||
// It sets the focused field to a constant value.
|
||||
// It sets the focused field to a constant value. This is a PURE operation (side-effect free).
|
||||
//
|
||||
// Parameters:
|
||||
// - lens: A lens that focuses on a field of type T within state S
|
||||
@@ -296,8 +349,10 @@ func LetL[S, T any](
|
||||
// readereither.LetToL(debugLens, false),
|
||||
// )
|
||||
// // result when executed will be Right(Config{Debug: false, Timeout: 30})
|
||||
//
|
||||
//go:inline
|
||||
func LetToL[S, T any](
|
||||
lens L.Lens[S, T],
|
||||
lens Lens[S, T],
|
||||
b T,
|
||||
) Kleisli[ReaderResult[S], S] {
|
||||
return LetTo(lens.Set, b)
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"context"
|
||||
|
||||
E "github.com/IBM/fp-go/v2/either"
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
)
|
||||
|
||||
// withContext wraps an existing ReaderResult and performs a context check for cancellation before deletating
|
||||
@@ -30,3 +31,11 @@ func WithContext[A any](ma ReaderResult[A]) ReaderResult[A] {
|
||||
return ma(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func WithContextK[A, B any](f Kleisli[A, B]) Kleisli[A, B] {
|
||||
return F.Flow2(
|
||||
f,
|
||||
WithContext,
|
||||
)
|
||||
}
|
||||
|
||||
51
v2/context/readerresult/filter.go
Normal file
51
v2/context/readerresult/filter.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package readerresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
RR "github.com/IBM/fp-go/v2/readerresult"
|
||||
)
|
||||
|
||||
// FilterOrElse filters a ReaderResult value based on a predicate.
|
||||
// This is a convenience wrapper around readerresult.FilterOrElse that fixes
|
||||
// the context type to context.Context.
|
||||
//
|
||||
// If the predicate returns true for the Right value, it passes through unchanged.
|
||||
// If the predicate returns false, it transforms the Right value into a Left (error) using onFalse.
|
||||
// Left values are passed through unchanged.
|
||||
//
|
||||
// Parameters:
|
||||
// - pred: A predicate function that tests the Right value
|
||||
// - onFalse: A function that converts the failing value into an error
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that filters ReaderResult values based on the predicate
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Validate that a number is positive
|
||||
// isPositive := N.MoreThan(0)
|
||||
// onNegative := func(n int) error { return fmt.Errorf("%d is not positive", n) }
|
||||
//
|
||||
// filter := readerresult.FilterOrElse(isPositive, onNegative)
|
||||
// result := filter(readerresult.Right(42))(context.Background())
|
||||
//
|
||||
//go:inline
|
||||
func FilterOrElse[A any](pred Predicate[A], onFalse func(A) error) Operator[A, A] {
|
||||
return RR.FilterOrElse[context.Context](pred, onFalse)
|
||||
}
|
||||
@@ -1,3 +1,18 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package readerresult
|
||||
|
||||
import (
|
||||
@@ -7,11 +22,131 @@ import (
|
||||
RR "github.com/IBM/fp-go/v2/readerresult"
|
||||
)
|
||||
|
||||
// SequenceReader swaps the order of environment parameters when the inner computation is a Reader.
|
||||
//
|
||||
// This function is specialized for the context.Context-based ReaderResult monad. It takes a
|
||||
// ReaderResult that produces a Reader and returns a reader.Kleisli that produces Results.
|
||||
// The context.Context is implicitly used as the outer environment type.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - R: The inner environment type (becomes outer after flip)
|
||||
// - A: The success value type
|
||||
//
|
||||
// Parameters:
|
||||
// - ma: A ReaderResult that takes context.Context and may produce a Reader[R, A]
|
||||
//
|
||||
// Returns:
|
||||
// - A reader.Kleisli[context.Context, R, Result[A]], which is func(context.Context) func(R) Result[A]
|
||||
//
|
||||
// The function preserves error handling from the outer ReaderResult layer. If the outer
|
||||
// computation fails, the error is propagated to the inner Result.
|
||||
//
|
||||
// Note: This is an inline wrapper around readerresult.SequenceReader, specialized for
|
||||
// context.Context as the outer environment type.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type Database struct {
|
||||
// ConnectionString string
|
||||
// }
|
||||
//
|
||||
// // Original: takes context, may fail, produces Reader[Database, string]
|
||||
// original := func(ctx context.Context) result.Result[reader.Reader[Database, string]] {
|
||||
// if ctx.Err() != nil {
|
||||
// return result.Error[reader.Reader[Database, string]](ctx.Err())
|
||||
// }
|
||||
// return result.Ok[error](func(db Database) string {
|
||||
// return fmt.Sprintf("Query on %s", db.ConnectionString)
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// // Sequenced: takes context first, then Database
|
||||
// sequenced := SequenceReader(original)
|
||||
//
|
||||
// ctx := context.Background()
|
||||
// db := Database{ConnectionString: "localhost:5432"}
|
||||
//
|
||||
// // Apply context first to get a function that takes database
|
||||
// dbReader := sequenced(ctx)
|
||||
// // Then apply database to get the final result
|
||||
// result := dbReader(db)
|
||||
// // result is Result[string]
|
||||
//
|
||||
// Use Cases:
|
||||
// - Dependency injection: Flip parameter order to inject context first, then dependencies
|
||||
// - Testing: Separate context handling from business logic for easier testing
|
||||
// - Composition: Enable point-free style by fixing the context parameter first
|
||||
//
|
||||
//go:inline
|
||||
func SequenceReader[R, A any](ma ReaderResult[Reader[R, A]]) reader.Kleisli[context.Context, R, Result[A]] {
|
||||
return RR.SequenceReader(ma)
|
||||
}
|
||||
|
||||
// TraverseReader transforms a value using a Reader function and swaps environment parameter order.
|
||||
//
|
||||
// This function combines mapping and parameter flipping in a single operation. It takes a
|
||||
// Reader function (pure computation without error handling) and returns a function that:
|
||||
// 1. Maps a ReaderResult[A] to ReaderResult[B] using the provided Reader function
|
||||
// 2. Flips the parameter order so R comes before context.Context
|
||||
//
|
||||
// Type Parameters:
|
||||
// - R: The inner environment type (becomes outer after flip)
|
||||
// - A: The input value type
|
||||
// - B: The output value type
|
||||
//
|
||||
// Parameters:
|
||||
// - f: A reader.Kleisli[R, A, B], which is func(R) func(A) B - a pure Reader function
|
||||
//
|
||||
// Returns:
|
||||
// - A function that takes ReaderResult[A] and returns Kleisli[R, B]
|
||||
// - Kleisli[R, B] is func(R) ReaderResult[B], which is func(R) func(context.Context) Result[B]
|
||||
//
|
||||
// The function preserves error handling from the input ReaderResult. If the input computation
|
||||
// fails, the error is propagated without applying the transformation function.
|
||||
//
|
||||
// Note: This is a wrapper around readerresult.TraverseReader, specialized for context.Context.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type Config struct {
|
||||
// MaxRetries int
|
||||
// }
|
||||
//
|
||||
// // A pure Reader function that depends on Config
|
||||
// formatMessage := func(cfg Config) func(int) string {
|
||||
// return func(value int) string {
|
||||
// return fmt.Sprintf("Value: %d, MaxRetries: %d", value, cfg.MaxRetries)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Original computation that may fail
|
||||
// computation := func(ctx context.Context) result.Result[int] {
|
||||
// if ctx.Err() != nil {
|
||||
// return result.Error[int](ctx.Err())
|
||||
// }
|
||||
// return result.Ok[error](42)
|
||||
// }
|
||||
//
|
||||
// // Create a traversal that applies formatMessage and flips parameters
|
||||
// traverse := TraverseReader[Config, int, string](formatMessage)
|
||||
//
|
||||
// // Apply to the computation
|
||||
// flipped := traverse(computation)
|
||||
//
|
||||
// // Now we can provide Config first, then context
|
||||
// cfg := Config{MaxRetries: 3}
|
||||
// ctx := context.Background()
|
||||
//
|
||||
// result := flipped(cfg)(ctx)
|
||||
// // result is Result[string] containing "Value: 42, MaxRetries: 3"
|
||||
//
|
||||
// Use Cases:
|
||||
// - Dependency injection: Inject configuration/dependencies before context
|
||||
// - Testing: Separate pure business logic from context handling
|
||||
// - Composition: Build pipelines where dependencies are fixed before execution
|
||||
// - Point-free style: Enable partial application by fixing dependencies first
|
||||
//
|
||||
//go:inline
|
||||
func TraverseReader[R, A, B any](
|
||||
f reader.Kleisli[R, A, B],
|
||||
) func(ReaderResult[A]) Kleisli[R, B] {
|
||||
|
||||
215
v2/context/readerresult/logging.go
Normal file
215
v2/context/readerresult/logging.go
Normal file
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package readerresult provides logging utilities for the ReaderResult monad,
|
||||
// which combines the Reader monad (for dependency injection via context.Context)
|
||||
// with the Result monad (for error handling).
|
||||
//
|
||||
// The logging functions in this package allow you to log Result values (both
|
||||
// successes and errors) while preserving the functional composition style.
|
||||
package readerresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/logging"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
)
|
||||
|
||||
// curriedLog creates a curried logging function that takes an slog.Attr and a context,
|
||||
// then logs the attribute with the specified log level and message.
|
||||
//
|
||||
// This is an internal helper function used to create the logging pipeline in a
|
||||
// point-free style. The currying allows for partial application in functional
|
||||
// composition.
|
||||
//
|
||||
// Parameters:
|
||||
// - logLevel: The slog.Level at which to log (e.g., LevelInfo, LevelError)
|
||||
// - cb: A callback function that retrieves a logger from the context
|
||||
// - message: The log message to display
|
||||
//
|
||||
// Returns:
|
||||
// - A curried function that takes an slog.Attr, then a context, and performs logging
|
||||
func curriedLog(
|
||||
logLevel slog.Level,
|
||||
cb func(context.Context) *slog.Logger,
|
||||
message string) func(slog.Attr) Reader[context.Context, struct{}] {
|
||||
return F.Curry2(func(a slog.Attr, ctx context.Context) struct{} {
|
||||
cb(ctx).LogAttrs(ctx, logLevel, message, a)
|
||||
return struct{}{}
|
||||
})
|
||||
}
|
||||
|
||||
// SLogWithCallback creates a Kleisli arrow that logs a Result value using a custom
|
||||
// logger callback and log level. The Result value is logged and then returned unchanged,
|
||||
// making this function suitable for use in functional pipelines.
|
||||
//
|
||||
// This function logs both successful values and errors:
|
||||
// - Success values are logged with the key "value"
|
||||
// - Error values are logged with the key "error"
|
||||
//
|
||||
// The logging is performed as a side effect while preserving the Result value,
|
||||
// allowing it to be used in the middle of a computation pipeline without
|
||||
// interrupting the flow.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of the success value in the Result
|
||||
//
|
||||
// Parameters:
|
||||
// - logLevel: The slog.Level at which to log (e.g., LevelInfo, LevelDebug, LevelError)
|
||||
// - cb: A callback function that retrieves a *slog.Logger from the context
|
||||
// - message: The log message to display
|
||||
//
|
||||
// Returns:
|
||||
// - A Kleisli arrow that takes a Result[A] and returns a ReaderResult[A]
|
||||
// The returned ReaderResult, when executed with a context, logs the Result
|
||||
// and returns it unchanged
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type User struct {
|
||||
// ID int
|
||||
// Name string
|
||||
// }
|
||||
//
|
||||
// // Custom logger callback
|
||||
// getLogger := func(ctx context.Context) *slog.Logger {
|
||||
// return slog.Default()
|
||||
// }
|
||||
//
|
||||
// // Create a logging function for debug level
|
||||
// logDebug := SLogWithCallback[User](slog.LevelDebug, getLogger, "User data")
|
||||
//
|
||||
// // Use in a pipeline
|
||||
// ctx := context.Background()
|
||||
// user := result.Of(User{ID: 123, Name: "Alice"})
|
||||
// logged := logDebug(user)(ctx) // Logs: level=DEBUG msg="User data" value={ID:123 Name:Alice}
|
||||
// // logged still contains the User value
|
||||
//
|
||||
// Example with error:
|
||||
//
|
||||
// err := errors.New("user not found")
|
||||
// userResult := result.Left[User](err)
|
||||
// logged := logDebug(userResult)(ctx) // Logs: level=DEBUG msg="User data" error="user not found"
|
||||
// // logged still contains the error
|
||||
func SLogWithCallback[A any](
|
||||
logLevel slog.Level,
|
||||
cb func(context.Context) *slog.Logger,
|
||||
message string) Kleisli[Result[A], A] {
|
||||
|
||||
return F.Pipe1(
|
||||
F.Flow2(
|
||||
result.ToSLogAttr[A](),
|
||||
curriedLog(logLevel, cb, message),
|
||||
),
|
||||
reader.Chain(reader.Sequence(F.Flow2( // this flow is basically the `MapTo` function with side effects
|
||||
reader.Of[struct{}, Result[A]],
|
||||
reader.Map[context.Context, struct{}, Result[A]],
|
||||
))),
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
// SLog creates a Kleisli arrow that logs a Result value at INFO level using the
|
||||
// logger from the context. This is a convenience function that uses SLogWithCallback
|
||||
// with default settings.
|
||||
//
|
||||
// The Result value is logged and then returned unchanged, making this function
|
||||
// suitable for use in functional pipelines for debugging or monitoring purposes.
|
||||
//
|
||||
// This function logs both successful values and errors:
|
||||
// - Success values are logged with the key "value"
|
||||
// - Error values are logged with the key "error"
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of the success value in the Result
|
||||
//
|
||||
// Parameters:
|
||||
// - message: The log message to display
|
||||
//
|
||||
// Returns:
|
||||
// - A Kleisli arrow that takes a Result[A] and returns a ReaderResult[A]
|
||||
// The returned ReaderResult, when executed with a context, logs the Result
|
||||
// at INFO level and returns it unchanged
|
||||
//
|
||||
// Example - Logging a successful computation:
|
||||
//
|
||||
// ctx := context.Background()
|
||||
//
|
||||
// // Simple value logging
|
||||
// res := result.Of(42)
|
||||
// logged := SLog[int]("Processing number")(res)(ctx)
|
||||
// // Logs: level=INFO msg="Processing number" value=42
|
||||
// // logged == result.Of(42)
|
||||
//
|
||||
// Example - Logging in a pipeline:
|
||||
//
|
||||
// type User struct {
|
||||
// ID int
|
||||
// Name string
|
||||
// }
|
||||
//
|
||||
// fetchUser := func(id int) result.Result[User] {
|
||||
// return result.Of(User{ID: id, Name: "Alice"})
|
||||
// }
|
||||
//
|
||||
// processUser := func(user User) result.Result[string] {
|
||||
// return result.Of(fmt.Sprintf("Processed: %s", user.Name))
|
||||
// }
|
||||
//
|
||||
// ctx := context.Background()
|
||||
//
|
||||
// // Log at each step
|
||||
// userResult := fetchUser(123)
|
||||
// logged1 := SLog[User]("Fetched user")(userResult)(ctx)
|
||||
// // Logs: level=INFO msg="Fetched user" value={ID:123 Name:Alice}
|
||||
//
|
||||
// processed := result.Chain(processUser)(logged1)
|
||||
// logged2 := SLog[string]("Processed user")(processed)(ctx)
|
||||
// // Logs: level=INFO msg="Processed user" value="Processed: Alice"
|
||||
//
|
||||
// Example - Logging errors:
|
||||
//
|
||||
// err := errors.New("database connection failed")
|
||||
// errResult := result.Left[User](err)
|
||||
// logged := SLog[User]("Database operation")(errResult)(ctx)
|
||||
// // Logs: level=INFO msg="Database operation" error="database connection failed"
|
||||
// // logged still contains the error
|
||||
//
|
||||
// Example - Using with context logger:
|
||||
//
|
||||
// // Set up a custom logger in the context
|
||||
// logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
// ctx := logging.WithLogger(logger)(context.Background())
|
||||
//
|
||||
// res := result.Of("important data")
|
||||
// logged := SLog[string]("Critical operation")(res)(ctx)
|
||||
// // Uses the logger from context to log the message
|
||||
//
|
||||
// Note: The function uses logging.GetLoggerFromContext to retrieve the logger,
|
||||
// which falls back to the global logger if no logger is found in the context.
|
||||
//
|
||||
//go:inline
|
||||
func SLog[A any](message string) Kleisli[Result[A], A] {
|
||||
return SLogWithCallback[A](slog.LevelInfo, logging.GetLoggerFromContext, message)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func TapSLog[A any](message string) Operator[A, A] {
|
||||
return reader.Chain(SLog[A](message))
|
||||
}
|
||||
302
v2/context/readerresult/logging_test.go
Normal file
302
v2/context/readerresult/logging_test.go
Normal file
@@ -0,0 +1,302 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package readerresult
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/IBM/fp-go/v2/logging"
|
||||
N "github.com/IBM/fp-go/v2/number"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestSLogLogsSuccessValue tests that SLog logs successful Result values
|
||||
func TestSLogLogsSuccessValue(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
oldLogger := logging.SetLogger(logger)
|
||||
defer logging.SetLogger(oldLogger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a Result and log it
|
||||
res1 := result.Of(42)
|
||||
logged := SLog[int]("Result value")(res1)(ctx)
|
||||
|
||||
assert.Equal(t, result.Of(42), logged)
|
||||
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Result value")
|
||||
assert.Contains(t, logOutput, "value=42")
|
||||
}
|
||||
|
||||
// TestSLogLogsErrorValue tests that SLog logs error Result values
|
||||
func TestSLogLogsErrorValue(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
oldLogger := logging.SetLogger(logger)
|
||||
defer logging.SetLogger(oldLogger)
|
||||
|
||||
ctx := context.Background()
|
||||
testErr := errors.New("test error")
|
||||
|
||||
// Create an error Result and log it
|
||||
res1 := result.Left[int](testErr)
|
||||
logged := SLog[int]("Result value")(res1)(ctx)
|
||||
|
||||
assert.Equal(t, res1, logged)
|
||||
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Result value")
|
||||
assert.Contains(t, logOutput, "error")
|
||||
assert.Contains(t, logOutput, "test error")
|
||||
}
|
||||
|
||||
// TestSLogInPipeline tests SLog in a functional pipeline
|
||||
func TestSLogInPipeline(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
oldLogger := logging.SetLogger(logger)
|
||||
defer logging.SetLogger(oldLogger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// SLog takes a Result[A] and returns ReaderResult[A]
|
||||
// So we need to start with a Result, apply SLog, then execute with context
|
||||
res1 := result.Of(10)
|
||||
logged := SLog[int]("Initial value")(res1)(ctx)
|
||||
|
||||
assert.Equal(t, result.Of(10), logged)
|
||||
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Initial value")
|
||||
assert.Contains(t, logOutput, "value=10")
|
||||
}
|
||||
|
||||
// TestSLogWithContextLogger tests SLog using logger from context
|
||||
func TestSLogWithContextLogger(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
contextLogger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
|
||||
ctx := logging.WithLogger(contextLogger)(context.Background())
|
||||
|
||||
res1 := result.Of("test value")
|
||||
logged := SLog[string]("Context logger test")(res1)(ctx)
|
||||
|
||||
assert.Equal(t, result.Of("test value"), logged)
|
||||
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Context logger test")
|
||||
assert.Contains(t, logOutput, `value="test value"`)
|
||||
}
|
||||
|
||||
// TestSLogDisabled tests that SLog respects logger level
|
||||
func TestSLogDisabled(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
// Create logger with level that disables info logs
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelError, // Only log errors
|
||||
}))
|
||||
oldLogger := logging.SetLogger(logger)
|
||||
defer logging.SetLogger(oldLogger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
res1 := result.Of(42)
|
||||
logged := SLog[int]("This should not be logged")(res1)(ctx)
|
||||
|
||||
assert.Equal(t, result.Of(42), logged)
|
||||
|
||||
// Should have no logs since level is ERROR
|
||||
logOutput := buf.String()
|
||||
assert.Empty(t, logOutput, "Should have no logs when logging is disabled")
|
||||
}
|
||||
|
||||
// TestSLogWithStruct tests SLog with structured data
|
||||
func TestSLogWithStruct(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
oldLogger := logging.SetLogger(logger)
|
||||
defer logging.SetLogger(oldLogger)
|
||||
|
||||
type User struct {
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
user := User{ID: 123, Name: "Alice"}
|
||||
|
||||
res1 := result.Of(user)
|
||||
logged := SLog[User]("User data")(res1)(ctx)
|
||||
|
||||
assert.Equal(t, result.Of(user), logged)
|
||||
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "User data")
|
||||
assert.Contains(t, logOutput, "ID:123")
|
||||
assert.Contains(t, logOutput, "Name:Alice")
|
||||
}
|
||||
|
||||
// TestSLogWithCallbackCustomLevel tests SLogWithCallback with custom log level
|
||||
func TestSLogWithCallbackCustomLevel(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
}))
|
||||
|
||||
customCallback := func(ctx context.Context) *slog.Logger {
|
||||
return logger
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a Result and log it with custom callback
|
||||
res1 := result.Of(42)
|
||||
logged := SLogWithCallback[int](slog.LevelDebug, customCallback, "Debug result")(res1)(ctx)
|
||||
|
||||
assert.Equal(t, result.Of(42), logged)
|
||||
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Debug result")
|
||||
assert.Contains(t, logOutput, "value=42")
|
||||
assert.Contains(t, logOutput, "level=DEBUG")
|
||||
}
|
||||
|
||||
// TestSLogWithCallbackLogsError tests SLogWithCallback logs errors
|
||||
func TestSLogWithCallbackLogsError(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelWarn,
|
||||
}))
|
||||
|
||||
customCallback := func(ctx context.Context) *slog.Logger {
|
||||
return logger
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
testErr := errors.New("warning error")
|
||||
|
||||
// Create an error Result and log it with custom callback
|
||||
res1 := result.Left[int](testErr)
|
||||
logged := SLogWithCallback[int](slog.LevelWarn, customCallback, "Warning result")(res1)(ctx)
|
||||
|
||||
assert.Equal(t, res1, logged)
|
||||
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Warning result")
|
||||
assert.Contains(t, logOutput, "error")
|
||||
assert.Contains(t, logOutput, "warning error")
|
||||
assert.Contains(t, logOutput, "level=WARN")
|
||||
}
|
||||
|
||||
// TestSLogChainedOperations tests SLog in chained operations
|
||||
func TestSLogChainedOperations(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
oldLogger := logging.SetLogger(logger)
|
||||
defer logging.SetLogger(oldLogger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// First log step 1
|
||||
res1 := result.Of(5)
|
||||
logged1 := SLog[int]("Step 1")(res1)(ctx)
|
||||
|
||||
// Then log step 2 with doubled value
|
||||
res2 := result.Map(N.Mul(2))(logged1)
|
||||
logged2 := SLog[int]("Step 2")(res2)(ctx)
|
||||
|
||||
assert.Equal(t, result.Of(10), logged2)
|
||||
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Step 1")
|
||||
assert.Contains(t, logOutput, "value=5")
|
||||
assert.Contains(t, logOutput, "Step 2")
|
||||
assert.Contains(t, logOutput, "value=10")
|
||||
}
|
||||
|
||||
// TestSLogPreservesError tests that SLog preserves error through the pipeline
|
||||
func TestSLogPreservesError(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
oldLogger := logging.SetLogger(logger)
|
||||
defer logging.SetLogger(oldLogger)
|
||||
|
||||
ctx := context.Background()
|
||||
testErr := errors.New("original error")
|
||||
|
||||
res1 := result.Left[int](testErr)
|
||||
logged := SLog[int]("Logging error")(res1)(ctx)
|
||||
|
||||
// Apply map to verify error is preserved
|
||||
res2 := result.Map(N.Mul(2))(logged)
|
||||
|
||||
assert.Equal(t, res1, res2)
|
||||
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Logging error")
|
||||
assert.Contains(t, logOutput, "original error")
|
||||
}
|
||||
|
||||
// TestSLogMultipleValues tests logging multiple different values
|
||||
func TestSLogMultipleValues(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
oldLogger := logging.SetLogger(logger)
|
||||
defer logging.SetLogger(oldLogger)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with different types
|
||||
intRes := SLog[int]("Integer")(result.Of(42))(ctx)
|
||||
assert.Equal(t, result.Of(42), intRes)
|
||||
|
||||
strRes := SLog[string]("String")(result.Of("hello"))(ctx)
|
||||
assert.Equal(t, result.Of("hello"), strRes)
|
||||
|
||||
boolRes := SLog[bool]("Boolean")(result.Of(true))(ctx)
|
||||
assert.Equal(t, result.Of(true), boolRes)
|
||||
|
||||
logOutput := buf.String()
|
||||
assert.Contains(t, logOutput, "Integer")
|
||||
assert.Contains(t, logOutput, "value=42")
|
||||
assert.Contains(t, logOutput, "String")
|
||||
assert.Contains(t, logOutput, "value=hello")
|
||||
assert.Contains(t, logOutput, "Boolean")
|
||||
assert.Contains(t, logOutput, "value=true")
|
||||
}
|
||||
@@ -18,9 +18,17 @@ package readerresult
|
||||
import (
|
||||
"context"
|
||||
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/internal/chain"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
"github.com/IBM/fp-go/v2/readereither"
|
||||
)
|
||||
|
||||
func FromReader[A any](r Reader[context.Context, A]) ReaderResult[A] {
|
||||
return readereither.FromReader[error](r)
|
||||
}
|
||||
|
||||
func FromEither[A any](e Either[A]) ReaderResult[A] {
|
||||
return readereither.FromEither[context.Context](e)
|
||||
}
|
||||
@@ -42,11 +50,11 @@ func Map[A, B any](f func(A) B) Operator[A, B] {
|
||||
}
|
||||
|
||||
func MonadChain[A, B any](ma ReaderResult[A], f Kleisli[A, B]) ReaderResult[B] {
|
||||
return readereither.MonadChain(ma, f)
|
||||
return readereither.MonadChain(ma, F.Flow2(f, WithContext))
|
||||
}
|
||||
|
||||
func Chain[A, B any](f Kleisli[A, B]) Operator[A, B] {
|
||||
return readereither.Chain(f)
|
||||
return readereither.Chain(F.Flow2(f, WithContext))
|
||||
}
|
||||
|
||||
func Of[A any](a A) ReaderResult[A] {
|
||||
@@ -65,8 +73,50 @@ func FromPredicate[A any](pred func(A) bool, onFalse func(A) error) Kleisli[A, A
|
||||
return readereither.FromPredicate[context.Context](pred, onFalse)
|
||||
}
|
||||
|
||||
// OrElse recovers from a Left (error) by providing an alternative computation with access to context.Context.
|
||||
// If the ReaderResult is Right, it returns the value unchanged.
|
||||
// If the ReaderResult is Left, it applies the provided function to the error value,
|
||||
// which returns a new ReaderResult that replaces the original.
|
||||
//
|
||||
// This is useful for error recovery, fallback logic, or chaining alternative computations
|
||||
// that need access to the context (for cancellation, deadlines, or values).
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Recover with context-aware fallback
|
||||
// recover := readerresult.OrElse(func(err error) readerresult.ReaderResult[int] {
|
||||
// if err.Error() == "not found" {
|
||||
// return func(ctx context.Context) result.Result[int] {
|
||||
// // Could check ctx.Err() here
|
||||
// return result.Of(42)
|
||||
// }
|
||||
// }
|
||||
// return readerresult.Left[int](err)
|
||||
// })
|
||||
//
|
||||
// OrElse recovers from a Left (error) by providing an alternative computation.
|
||||
// If the ReaderResult is Right, it returns the value unchanged.
|
||||
// If the ReaderResult is Left, it applies the provided function to the error value,
|
||||
// which returns a new ReaderResult that replaces the original.
|
||||
//
|
||||
// This is useful for error recovery, fallback logic, or chaining alternative computations
|
||||
// in the context of Reader computations with context.Context.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Recover from specific errors with fallback values
|
||||
// recover := readerresult.OrElse(func(err error) readerresult.ReaderResult[int] {
|
||||
// if err.Error() == "not found" {
|
||||
// return readerresult.Of[int](0) // default value
|
||||
// }
|
||||
// return readerresult.Left[int](err) // propagate other errors
|
||||
// })
|
||||
// result := recover(readerresult.Left[int](errors.New("not found")))(ctx) // Right(0)
|
||||
// result := recover(readerresult.Of(42))(ctx) // Right(42) - unchanged
|
||||
//
|
||||
//go:inline
|
||||
func OrElse[A any](onLeft Kleisli[error, A]) Kleisli[ReaderResult[A], A] {
|
||||
return readereither.OrElse(onLeft)
|
||||
return readereither.OrElse(F.Flow2(onLeft, WithContext))
|
||||
}
|
||||
|
||||
func Ask() ReaderResult[context.Context] {
|
||||
@@ -81,7 +131,7 @@ func ChainEitherK[A, B any](f func(A) Either[B]) func(ma ReaderResult[A]) Reader
|
||||
return readereither.ChainEitherK[context.Context](f)
|
||||
}
|
||||
|
||||
func ChainOptionK[A, B any](onNone func() error) func(func(A) Option[B]) Operator[A, B] {
|
||||
func ChainOptionK[A, B any](onNone func() error) func(option.Kleisli[A, B]) Operator[A, B] {
|
||||
return readereither.ChainOptionK[context.Context, A, B](onNone)
|
||||
}
|
||||
|
||||
@@ -97,3 +147,197 @@ func Flap[B, A any](a A) Operator[func(A) B, B] {
|
||||
func Read[A any](r context.Context) func(ReaderResult[A]) Result[A] {
|
||||
return readereither.Read[error, A](r)
|
||||
}
|
||||
|
||||
// MonadMapTo executes a ReaderResult computation, discards its success value, and returns a constant value.
|
||||
// This is the monadic version that takes both the ReaderResult and the constant value as parameters.
|
||||
//
|
||||
// IMPORTANT: ReaderResult represents a side-effectful computation because it depends on context.Context,
|
||||
// which is effectful (can be cancelled, has deadlines, carries values). For this reason, MonadMapTo WILL
|
||||
// execute the original ReaderResult to allow any side effects to occur, then discard the success result
|
||||
// and return the constant value. If the original computation fails, the error is preserved.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The success type of the first ReaderResult (will be discarded if successful)
|
||||
// - B: The type of the constant value to return on success
|
||||
//
|
||||
// Parameters:
|
||||
// - ma: The ReaderResult to execute (side effects will occur, success value discarded)
|
||||
// - b: The constant value to return if ma succeeds
|
||||
//
|
||||
// Returns:
|
||||
// - A ReaderResult that executes ma, preserves errors, but replaces success values with b
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type Config struct { Counter int }
|
||||
// increment := func(ctx context.Context) result.Result[int] {
|
||||
// // Side effect: log the operation
|
||||
// fmt.Println("incrementing")
|
||||
// return result.Of(5)
|
||||
// }
|
||||
// r := readerresult.MonadMapTo(increment, "done")
|
||||
// result := r(context.Background()) // Prints "incrementing", returns Right("done")
|
||||
//
|
||||
//go:inline
|
||||
func MonadMapTo[A, B any](ma ReaderResult[A], b B) ReaderResult[B] {
|
||||
return MonadMap(ma, reader.Of[A](b))
|
||||
}
|
||||
|
||||
// MapTo creates an operator that executes a ReaderResult computation, discards its success value,
|
||||
// and returns a constant value. This is the curried version where the constant value is provided first,
|
||||
// returning a function that can be applied to any ReaderResult.
|
||||
//
|
||||
// IMPORTANT: ReaderResult represents a side-effectful computation because it depends on context.Context,
|
||||
// which is effectful (can be cancelled, has deadlines, carries values). For this reason, MapTo WILL
|
||||
// execute the input ReaderResult to allow any side effects to occur, then discard the success result
|
||||
// and return the constant value. If the computation fails, the error is preserved.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The success type of the input ReaderResult (will be discarded if successful)
|
||||
// - B: The type of the constant value to return on success
|
||||
//
|
||||
// Parameters:
|
||||
// - b: The constant value to return on success
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that executes a ReaderResult[A], preserves errors, but replaces success with b
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// logStep := func(ctx context.Context) result.Result[int] {
|
||||
// fmt.Println("step executed")
|
||||
// return result.Of(42)
|
||||
// }
|
||||
// toDone := readerresult.MapTo[int, string]("done")
|
||||
// pipeline := toDone(logStep)
|
||||
// result := pipeline(context.Background()) // Prints "step executed", returns Right("done")
|
||||
//
|
||||
// Example - In a functional pipeline:
|
||||
//
|
||||
// step1 := func(ctx context.Context) result.Result[int] {
|
||||
// fmt.Println("processing")
|
||||
// return result.Of(1)
|
||||
// }
|
||||
// pipeline := F.Pipe1(
|
||||
// step1,
|
||||
// readerresult.MapTo[int, string]("complete"),
|
||||
// )
|
||||
// output := pipeline(context.Background()) // Prints "processing", returns Right("complete")
|
||||
//
|
||||
//go:inline
|
||||
func MapTo[A, B any](b B) Operator[A, B] {
|
||||
return Map(reader.Of[A](b))
|
||||
}
|
||||
|
||||
// MonadChainTo sequences two ReaderResult computations where the second ignores the first's success value.
|
||||
// This is the monadic version that takes both ReaderResults as parameters.
|
||||
//
|
||||
// IMPORTANT: ReaderResult represents a side-effectful computation because it depends on context.Context,
|
||||
// which is effectful (can be cancelled, has deadlines, carries values). For this reason, MonadChainTo WILL
|
||||
// execute the first ReaderResult to allow any side effects to occur, then discard the success result and
|
||||
// execute the second ReaderResult with the same context. If the first computation fails, the error is
|
||||
// returned immediately without executing the second computation.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The success type of the first ReaderResult (will be discarded if successful)
|
||||
// - B: The success type of the second ReaderResult
|
||||
//
|
||||
// Parameters:
|
||||
// - ma: The first ReaderResult to execute (side effects will occur, success value discarded)
|
||||
// - b: The second ReaderResult to execute if ma succeeds
|
||||
//
|
||||
// Returns:
|
||||
// - A ReaderResult that executes ma, then b if ma succeeds, returning b's result
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// logStart := func(ctx context.Context) result.Result[int] {
|
||||
// fmt.Println("starting")
|
||||
// return result.Of(1)
|
||||
// }
|
||||
// logEnd := func(ctx context.Context) result.Result[string] {
|
||||
// fmt.Println("ending")
|
||||
// return result.Of("done")
|
||||
// }
|
||||
// r := readerresult.MonadChainTo(logStart, logEnd)
|
||||
// result := r(context.Background()) // Prints "starting" then "ending", returns Right("done")
|
||||
//
|
||||
//go:inline
|
||||
func MonadChainTo[A, B any](ma ReaderResult[A], b ReaderResult[B]) ReaderResult[B] {
|
||||
return MonadChain(ma, reader.Of[A](b))
|
||||
}
|
||||
|
||||
// ChainTo creates an operator that sequences two ReaderResult computations where the second ignores
|
||||
// the first's success value. This is the curried version where the second ReaderResult is provided first,
|
||||
// returning a function that can be applied to any first ReaderResult.
|
||||
//
|
||||
// IMPORTANT: ReaderResult represents a side-effectful computation because it depends on context.Context,
|
||||
// which is effectful (can be cancelled, has deadlines, carries values). For this reason, ChainTo WILL
|
||||
// execute the first ReaderResult to allow any side effects to occur, then discard the success result and
|
||||
// execute the second ReaderResult with the same context. If the first computation fails, the error is
|
||||
// returned immediately without executing the second computation.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The success type of the first ReaderResult (will be discarded if successful)
|
||||
// - B: The success type of the second ReaderResult
|
||||
//
|
||||
// Parameters:
|
||||
// - b: The second ReaderResult to execute after the first succeeds
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that executes the first ReaderResult, then b if successful
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// logEnd := func(ctx context.Context) result.Result[string] {
|
||||
// fmt.Println("ending")
|
||||
// return result.Of("done")
|
||||
// }
|
||||
// thenLogEnd := readerresult.ChainTo[int, string](logEnd)
|
||||
//
|
||||
// logStart := func(ctx context.Context) result.Result[int] {
|
||||
// fmt.Println("starting")
|
||||
// return result.Of(1)
|
||||
// }
|
||||
// pipeline := thenLogEnd(logStart)
|
||||
// result := pipeline(context.Background()) // Prints "starting" then "ending", returns Right("done")
|
||||
//
|
||||
// Example - In a functional pipeline:
|
||||
//
|
||||
// step1 := func(ctx context.Context) result.Result[int] {
|
||||
// fmt.Println("step 1")
|
||||
// return result.Of(1)
|
||||
// }
|
||||
// step2 := func(ctx context.Context) result.Result[string] {
|
||||
// fmt.Println("step 2")
|
||||
// return result.Of("complete")
|
||||
// }
|
||||
// pipeline := F.Pipe1(
|
||||
// step1,
|
||||
// readerresult.ChainTo[int, string](step2),
|
||||
// )
|
||||
// output := pipeline(context.Background()) // Prints "step 1" then "step 2", returns Right("complete")
|
||||
//
|
||||
//go:inline
|
||||
func ChainTo[A, B any](b ReaderResult[B]) Operator[A, B] {
|
||||
return Chain(reader.Of[A](b))
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func MonadChainFirst[A, B any](ma ReaderResult[A], f Kleisli[A, B]) ReaderResult[A] {
|
||||
return chain.MonadChainFirst(
|
||||
MonadChain,
|
||||
MonadMap,
|
||||
ma,
|
||||
F.Flow2(f, WithContext),
|
||||
)
|
||||
}
|
||||
|
||||
//go:inline
|
||||
func ChainFirst[A, B any](f Kleisli[A, B]) Operator[A, A] {
|
||||
return chain.ChainFirst(
|
||||
Chain,
|
||||
Map,
|
||||
F.Flow2(f, WithContext),
|
||||
)
|
||||
}
|
||||
|
||||
382
v2/context/readerresult/reader_test.go
Normal file
382
v2/context/readerresult/reader_test.go
Normal file
@@ -0,0 +1,382 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package readerresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
E "github.com/IBM/fp-go/v2/either"
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMapTo(t *testing.T) {
|
||||
t.Run("executes original reader and returns constant value on success", func(t *testing.T) {
|
||||
executed := false
|
||||
originalReader := func(ctx context.Context) E.Either[error, int] {
|
||||
executed = true
|
||||
return E.Of[error](42)
|
||||
}
|
||||
|
||||
// Apply MapTo operator
|
||||
toDone := MapTo[int]("done")
|
||||
resultReader := toDone(originalReader)
|
||||
|
||||
// Execute the resulting reader
|
||||
result := resultReader(context.Background())
|
||||
|
||||
// Verify the constant value is returned
|
||||
assert.Equal(t, E.Of[error]("done"), result)
|
||||
// Verify the original reader WAS executed (side effect occurred)
|
||||
assert.True(t, executed, "original reader should be executed to allow side effects")
|
||||
})
|
||||
|
||||
t.Run("executes reader in functional pipeline", func(t *testing.T) {
|
||||
executed := false
|
||||
step1 := func(ctx context.Context) E.Either[error, int] {
|
||||
executed = true
|
||||
return E.Of[error](100)
|
||||
}
|
||||
|
||||
pipeline := F.Pipe1(
|
||||
step1,
|
||||
MapTo[int]("complete"),
|
||||
)
|
||||
|
||||
result := pipeline(context.Background())
|
||||
|
||||
assert.Equal(t, E.Of[error]("complete"), result)
|
||||
assert.True(t, executed, "original reader should be executed in pipeline")
|
||||
})
|
||||
|
||||
t.Run("executes reader with side effects", func(t *testing.T) {
|
||||
sideEffectOccurred := false
|
||||
readerWithSideEffect := func(ctx context.Context) E.Either[error, int] {
|
||||
sideEffectOccurred = true
|
||||
return E.Of[error](42)
|
||||
}
|
||||
|
||||
resultReader := MapTo[int](true)(readerWithSideEffect)
|
||||
result := resultReader(context.Background())
|
||||
|
||||
assert.Equal(t, E.Of[error](true), result)
|
||||
assert.True(t, sideEffectOccurred, "side effect should occur")
|
||||
})
|
||||
|
||||
t.Run("preserves errors from original reader", func(t *testing.T) {
|
||||
executed := false
|
||||
testErr := assert.AnError
|
||||
failingReader := func(ctx context.Context) E.Either[error, int] {
|
||||
executed = true
|
||||
return E.Left[int](testErr)
|
||||
}
|
||||
|
||||
resultReader := MapTo[int]("done")(failingReader)
|
||||
result := resultReader(context.Background())
|
||||
|
||||
assert.Equal(t, E.Left[string](testErr), result)
|
||||
assert.True(t, executed, "failing reader should still be executed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMonadMapTo(t *testing.T) {
|
||||
t.Run("executes original reader and returns constant value on success", func(t *testing.T) {
|
||||
executed := false
|
||||
originalReader := func(ctx context.Context) E.Either[error, int] {
|
||||
executed = true
|
||||
return E.Of[error](42)
|
||||
}
|
||||
|
||||
// Apply MonadMapTo
|
||||
resultReader := MonadMapTo(originalReader, "done")
|
||||
|
||||
// Execute the resulting reader
|
||||
result := resultReader(context.Background())
|
||||
|
||||
// Verify the constant value is returned
|
||||
assert.Equal(t, E.Of[error]("done"), result)
|
||||
// Verify the original reader WAS executed (side effect occurred)
|
||||
assert.True(t, executed, "original reader should be executed to allow side effects")
|
||||
})
|
||||
|
||||
t.Run("executes complex computation with side effects", func(t *testing.T) {
|
||||
computationExecuted := false
|
||||
complexReader := func(ctx context.Context) E.Either[error, string] {
|
||||
computationExecuted = true
|
||||
return E.Of[error]("complex result")
|
||||
}
|
||||
|
||||
resultReader := MonadMapTo(complexReader, 42)
|
||||
result := resultReader(context.Background())
|
||||
|
||||
assert.Equal(t, E.Of[error](42), result)
|
||||
assert.True(t, computationExecuted, "complex computation should be executed")
|
||||
})
|
||||
|
||||
t.Run("preserves errors from original reader", func(t *testing.T) {
|
||||
executed := false
|
||||
testErr := assert.AnError
|
||||
failingReader := func(ctx context.Context) E.Either[error, []string] {
|
||||
executed = true
|
||||
return E.Left[[]string](testErr)
|
||||
}
|
||||
|
||||
resultReader := MonadMapTo(failingReader, 99)
|
||||
result := resultReader(context.Background())
|
||||
|
||||
assert.Equal(t, E.Left[int](testErr), result)
|
||||
assert.True(t, executed, "failing reader should still be executed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestChainTo(t *testing.T) {
|
||||
t.Run("executes first reader then second reader on success", func(t *testing.T) {
|
||||
firstExecuted := false
|
||||
secondExecuted := false
|
||||
|
||||
firstReader := func(ctx context.Context) E.Either[error, int] {
|
||||
firstExecuted = true
|
||||
return E.Of[error](42)
|
||||
}
|
||||
|
||||
secondReader := func(ctx context.Context) E.Either[error, string] {
|
||||
secondExecuted = true
|
||||
return E.Of[error]("result")
|
||||
}
|
||||
|
||||
// Apply ChainTo operator
|
||||
thenSecond := ChainTo[int](secondReader)
|
||||
resultReader := thenSecond(firstReader)
|
||||
|
||||
// Execute the resulting reader
|
||||
result := resultReader(context.Background())
|
||||
|
||||
// Verify the second reader's result is returned
|
||||
assert.Equal(t, E.Of[error]("result"), result)
|
||||
// Verify both readers were executed
|
||||
assert.True(t, firstExecuted, "first reader should be executed")
|
||||
assert.True(t, secondExecuted, "second reader should be executed")
|
||||
})
|
||||
|
||||
t.Run("executes both readers in functional pipeline", func(t *testing.T) {
|
||||
firstExecuted := false
|
||||
secondExecuted := false
|
||||
|
||||
step1 := func(ctx context.Context) E.Either[error, int] {
|
||||
firstExecuted = true
|
||||
return E.Of[error](100)
|
||||
}
|
||||
|
||||
step2 := func(ctx context.Context) E.Either[error, string] {
|
||||
secondExecuted = true
|
||||
return E.Of[error]("complete")
|
||||
}
|
||||
|
||||
pipeline := F.Pipe1(
|
||||
step1,
|
||||
ChainTo[int](step2),
|
||||
)
|
||||
|
||||
result := pipeline(context.Background())
|
||||
|
||||
assert.Equal(t, E.Of[error]("complete"), result)
|
||||
assert.True(t, firstExecuted, "first reader should be executed in pipeline")
|
||||
assert.True(t, secondExecuted, "second reader should be executed in pipeline")
|
||||
})
|
||||
|
||||
t.Run("executes first reader with side effects", func(t *testing.T) {
|
||||
sideEffectOccurred := false
|
||||
readerWithSideEffect := func(ctx context.Context) E.Either[error, int] {
|
||||
sideEffectOccurred = true
|
||||
return E.Of[error](42)
|
||||
}
|
||||
|
||||
secondReader := func(ctx context.Context) E.Either[error, bool] {
|
||||
return E.Of[error](true)
|
||||
}
|
||||
|
||||
resultReader := ChainTo[int](secondReader)(readerWithSideEffect)
|
||||
result := resultReader(context.Background())
|
||||
|
||||
assert.Equal(t, E.Of[error](true), result)
|
||||
assert.True(t, sideEffectOccurred, "side effect should occur in first reader")
|
||||
})
|
||||
|
||||
t.Run("preserves error from first reader without executing second", func(t *testing.T) {
|
||||
firstExecuted := false
|
||||
secondExecuted := false
|
||||
testErr := assert.AnError
|
||||
|
||||
failingReader := func(ctx context.Context) E.Either[error, int] {
|
||||
firstExecuted = true
|
||||
return E.Left[int](testErr)
|
||||
}
|
||||
|
||||
secondReader := func(ctx context.Context) E.Either[error, string] {
|
||||
secondExecuted = true
|
||||
return E.Of[error]("result")
|
||||
}
|
||||
|
||||
resultReader := ChainTo[int](secondReader)(failingReader)
|
||||
result := resultReader(context.Background())
|
||||
|
||||
assert.Equal(t, E.Left[string](testErr), result)
|
||||
assert.True(t, firstExecuted, "first reader should be executed")
|
||||
assert.False(t, secondExecuted, "second reader should not be executed on error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMonadChainTo(t *testing.T) {
|
||||
t.Run("executes first reader then second reader on success", func(t *testing.T) {
|
||||
firstExecuted := false
|
||||
secondExecuted := false
|
||||
|
||||
firstReader := func(ctx context.Context) E.Either[error, int] {
|
||||
firstExecuted = true
|
||||
return E.Of[error](42)
|
||||
}
|
||||
|
||||
secondReader := func(ctx context.Context) E.Either[error, string] {
|
||||
secondExecuted = true
|
||||
return E.Of[error]("result")
|
||||
}
|
||||
|
||||
// Apply MonadChainTo
|
||||
resultReader := MonadChainTo(firstReader, secondReader)
|
||||
|
||||
// Execute the resulting reader
|
||||
result := resultReader(context.Background())
|
||||
|
||||
// Verify the second reader's result is returned
|
||||
assert.Equal(t, E.Of[error]("result"), result)
|
||||
// Verify both readers were executed
|
||||
assert.True(t, firstExecuted, "first reader should be executed")
|
||||
assert.True(t, secondExecuted, "second reader should be executed")
|
||||
})
|
||||
|
||||
t.Run("executes complex first computation with side effects", func(t *testing.T) {
|
||||
firstExecuted := false
|
||||
secondExecuted := false
|
||||
|
||||
complexFirstReader := func(ctx context.Context) E.Either[error, []int] {
|
||||
firstExecuted = true
|
||||
return E.Of[error]([]int{1, 2, 3})
|
||||
}
|
||||
|
||||
secondReader := func(ctx context.Context) E.Either[error, string] {
|
||||
secondExecuted = true
|
||||
return E.Of[error]("done")
|
||||
}
|
||||
|
||||
resultReader := MonadChainTo(complexFirstReader, secondReader)
|
||||
result := resultReader(context.Background())
|
||||
|
||||
assert.Equal(t, E.Of[error]("done"), result)
|
||||
assert.True(t, firstExecuted, "complex first computation should be executed")
|
||||
assert.True(t, secondExecuted, "second reader should be executed")
|
||||
})
|
||||
|
||||
t.Run("preserves error from first reader without executing second", func(t *testing.T) {
|
||||
firstExecuted := false
|
||||
secondExecuted := false
|
||||
testErr := assert.AnError
|
||||
|
||||
failingReader := func(ctx context.Context) E.Either[error, map[string]int] {
|
||||
firstExecuted = true
|
||||
return E.Left[map[string]int](testErr)
|
||||
}
|
||||
|
||||
secondReader := func(ctx context.Context) E.Either[error, float64] {
|
||||
secondExecuted = true
|
||||
return E.Of[error](3.14)
|
||||
}
|
||||
|
||||
resultReader := MonadChainTo(failingReader, secondReader)
|
||||
result := resultReader(context.Background())
|
||||
|
||||
assert.Equal(t, E.Left[float64](testErr), result)
|
||||
assert.True(t, firstExecuted, "first reader should be executed")
|
||||
assert.False(t, secondExecuted, "second reader should not be executed on error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestOrElse(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test OrElse with Right - should pass through unchanged
|
||||
t.Run("Right value unchanged", func(t *testing.T) {
|
||||
rightValue := Of(42)
|
||||
recover := OrElse(func(err error) ReaderResult[int] {
|
||||
return Left[int](errors.New("should not be called"))
|
||||
})
|
||||
res := recover(rightValue)(ctx)
|
||||
assert.Equal(t, E.Of[error](42), res)
|
||||
})
|
||||
|
||||
// Test OrElse with Left - should recover with fallback
|
||||
t.Run("Left value recovered", func(t *testing.T) {
|
||||
leftValue := Left[int](errors.New("not found"))
|
||||
recoverWithFallback := OrElse(func(err error) ReaderResult[int] {
|
||||
if err.Error() == "not found" {
|
||||
return func(ctx context.Context) E.Either[error, int] {
|
||||
return E.Of[error](99)
|
||||
}
|
||||
}
|
||||
return Left[int](err)
|
||||
})
|
||||
res := recoverWithFallback(leftValue)(ctx)
|
||||
assert.Equal(t, E.Of[error](99), res)
|
||||
})
|
||||
|
||||
// Test OrElse with Left - should propagate other errors
|
||||
t.Run("Left value propagated", func(t *testing.T) {
|
||||
leftValue := Left[int](errors.New("fatal error"))
|
||||
recoverWithFallback := OrElse(func(err error) ReaderResult[int] {
|
||||
if err.Error() == "not found" {
|
||||
return Of(99)
|
||||
}
|
||||
return Left[int](err)
|
||||
})
|
||||
res := recoverWithFallback(leftValue)(ctx)
|
||||
assert.True(t, E.IsLeft(res))
|
||||
val, err := E.UnwrapError(res)
|
||||
assert.Equal(t, 0, val)
|
||||
assert.Equal(t, "fatal error", err.Error())
|
||||
})
|
||||
|
||||
// Test OrElse with context-aware recovery
|
||||
t.Run("Context-aware recovery", func(t *testing.T) {
|
||||
type ctxKey string
|
||||
ctxWithValue := context.WithValue(ctx, ctxKey("fallback"), 123)
|
||||
|
||||
leftValue := Left[int](errors.New("use fallback"))
|
||||
ctxRecover := OrElse(func(err error) ReaderResult[int] {
|
||||
if err.Error() == "use fallback" {
|
||||
return func(ctx context.Context) E.Either[error, int] {
|
||||
if val := ctx.Value(ctxKey("fallback")); val != nil {
|
||||
return E.Of[error](val.(int))
|
||||
}
|
||||
return E.Left[int](errors.New("no fallback"))
|
||||
}
|
||||
}
|
||||
return Left[int](err)
|
||||
})
|
||||
res := ctxRecover(leftValue)(ctxWithValue)
|
||||
assert.Equal(t, E.Of[error](123), res)
|
||||
})
|
||||
}
|
||||
105
v2/context/readerresult/rec.go
Normal file
105
v2/context/readerresult/rec.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package readerresult implements a specialization of the Reader monad assuming a golang context as the context of the monad and a standard golang error
|
||||
package readerresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/IBM/fp-go/v2/either"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
)
|
||||
|
||||
// TailRec implements tail-recursive computation for ReaderResult with context cancellation support.
|
||||
//
|
||||
// TailRec takes a Kleisli function that returns Trampoline[A, B] and converts it into a stack-safe,
|
||||
// tail-recursive computation. The function repeatedly applies the Kleisli until it produces a Land value.
|
||||
//
|
||||
// The implementation includes a short-circuit mechanism that checks for context cancellation on each
|
||||
// iteration. If the context is canceled (ctx.Err() != nil), the computation immediately returns a
|
||||
// Left result containing the context's cause error, preventing unnecessary computation.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The input type for the recursive step
|
||||
// - B: The final result type
|
||||
//
|
||||
// Parameters:
|
||||
// - f: A Kleisli function that takes an A and returns a ReaderResult containing Trampoline[A, B].
|
||||
// When the result is Bounce(a), recursion continues with the new value 'a'.
|
||||
// When the result is Land(b), recursion terminates with the final value 'b'.
|
||||
//
|
||||
// Returns:
|
||||
// - A Kleisli function that performs the tail-recursive computation in a stack-safe manner.
|
||||
//
|
||||
// Behavior:
|
||||
// - On each iteration, checks if the context has been canceled (short circuit)
|
||||
// - If canceled, returns result.Left[B](context.Cause(ctx))
|
||||
// - If the step returns Left[B](error), propagates the error
|
||||
// - If the step returns Right[A](Bounce(a)), continues recursion with new value 'a'
|
||||
// - If the step returns Right[A](Land(b)), terminates with success value 'b'
|
||||
//
|
||||
// Example - Factorial computation with context:
|
||||
//
|
||||
// type State struct {
|
||||
// n int
|
||||
// acc int
|
||||
// }
|
||||
//
|
||||
// factorialStep := func(state State) ReaderResult[tailrec.Trampoline[State, int]] {
|
||||
// return func(ctx context.Context) result.Result[tailrec.Trampoline[State, int]] {
|
||||
// if state.n <= 0 {
|
||||
// return result.Of(tailrec.Land[State](state.acc))
|
||||
// }
|
||||
// return result.Of(tailrec.Bounce[int](State{state.n - 1, state.acc * state.n}))
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// factorial := TailRec(factorialStep)
|
||||
// result := factorial(State{5, 1})(ctx) // Returns result.Of(120)
|
||||
//
|
||||
// Example - Context cancellation:
|
||||
//
|
||||
// ctx, cancel := context.WithCancel(context.Background())
|
||||
// cancel() // Cancel immediately
|
||||
//
|
||||
// computation := TailRec(someStep)
|
||||
// result := computation(initialValue)(ctx)
|
||||
// // Returns result.Left[B](context.Cause(ctx)) without executing any steps
|
||||
//
|
||||
//go:inline
|
||||
func TailRec[A, B any](f Kleisli[A, Trampoline[A, B]]) Kleisli[A, B] {
|
||||
return func(a A) ReaderResult[B] {
|
||||
initialReader := f(a)
|
||||
return func(ctx context.Context) result.Result[B] {
|
||||
rdr := initialReader
|
||||
for {
|
||||
// short circuit
|
||||
if ctx.Err() != nil {
|
||||
return result.Left[B](context.Cause(ctx))
|
||||
}
|
||||
current := rdr(ctx)
|
||||
rec, e := either.Unwrap(current)
|
||||
if either.IsLeft(current) {
|
||||
return result.Left[B](e)
|
||||
}
|
||||
if rec.Landed {
|
||||
return result.Of(rec.Land)
|
||||
}
|
||||
rdr = f(rec.Bounce)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
498
v2/context/readerresult/rec_test.go
Normal file
498
v2/context/readerresult/rec_test.go
Normal file
@@ -0,0 +1,498 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package readerresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
A "github.com/IBM/fp-go/v2/array"
|
||||
R "github.com/IBM/fp-go/v2/result"
|
||||
TR "github.com/IBM/fp-go/v2/tailrec"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestTailRecFactorial tests factorial computation with context
|
||||
func TestTailRecFactorial(t *testing.T) {
|
||||
type State struct {
|
||||
n int
|
||||
acc int
|
||||
}
|
||||
|
||||
factorialStep := func(state State) ReaderResult[TR.Trampoline[State, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[State, int]] {
|
||||
if state.n <= 0 {
|
||||
return R.Of(TR.Land[State](state.acc))
|
||||
}
|
||||
return R.Of(TR.Bounce[int](State{state.n - 1, state.acc * state.n}))
|
||||
}
|
||||
}
|
||||
|
||||
factorial := TailRec(factorialStep)
|
||||
result := factorial(State{5, 1})(context.Background())
|
||||
|
||||
assert.Equal(t, R.Of(120), result)
|
||||
}
|
||||
|
||||
// TestTailRecFibonacci tests Fibonacci computation
|
||||
func TestTailRecFibonacci(t *testing.T) {
|
||||
type State struct {
|
||||
n int
|
||||
prev int
|
||||
curr int
|
||||
}
|
||||
|
||||
fibStep := func(state State) ReaderResult[TR.Trampoline[State, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[State, int]] {
|
||||
if state.n <= 0 {
|
||||
return R.Of(TR.Land[State](state.curr))
|
||||
}
|
||||
return R.Of(TR.Bounce[int](State{state.n - 1, state.curr, state.prev + state.curr}))
|
||||
}
|
||||
}
|
||||
|
||||
fib := TailRec(fibStep)
|
||||
result := fib(State{10, 0, 1})(context.Background())
|
||||
|
||||
assert.Equal(t, R.Of(89), result) // 10th Fibonacci number
|
||||
}
|
||||
|
||||
// TestTailRecCountdown tests countdown computation
|
||||
func TestTailRecCountdown(t *testing.T) {
|
||||
countdownStep := func(n int) ReaderResult[TR.Trampoline[int, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[int, int]] {
|
||||
if n <= 0 {
|
||||
return R.Of(TR.Land[int](n))
|
||||
}
|
||||
return R.Of(TR.Bounce[int](n - 1))
|
||||
}
|
||||
}
|
||||
|
||||
countdown := TailRec(countdownStep)
|
||||
result := countdown(10)(context.Background())
|
||||
|
||||
assert.Equal(t, R.Of(0), result)
|
||||
}
|
||||
|
||||
// TestTailRecImmediateTermination tests immediate termination (Right on first call)
|
||||
func TestTailRecImmediateTermination(t *testing.T) {
|
||||
immediateStep := func(n int) ReaderResult[TR.Trampoline[int, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[int, int]] {
|
||||
return R.Of(TR.Land[int](n * 2))
|
||||
}
|
||||
}
|
||||
|
||||
immediate := TailRec(immediateStep)
|
||||
result := immediate(42)(context.Background())
|
||||
|
||||
assert.Equal(t, R.Of(84), result)
|
||||
}
|
||||
|
||||
// TestTailRecStackSafety tests that TailRec handles large iterations without stack overflow
|
||||
func TestTailRecStackSafety(t *testing.T) {
|
||||
countdownStep := func(n int) ReaderResult[TR.Trampoline[int, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[int, int]] {
|
||||
if n <= 0 {
|
||||
return R.Of(TR.Land[int](n))
|
||||
}
|
||||
return R.Of(TR.Bounce[int](n - 1))
|
||||
}
|
||||
}
|
||||
|
||||
countdown := TailRec(countdownStep)
|
||||
result := countdown(10000)(context.Background())
|
||||
|
||||
assert.Equal(t, R.Of(0), result)
|
||||
}
|
||||
|
||||
// TestTailRecSumList tests summing a list
|
||||
func TestTailRecSumList(t *testing.T) {
|
||||
type State struct {
|
||||
list []int
|
||||
sum int
|
||||
}
|
||||
|
||||
sumStep := func(state State) ReaderResult[TR.Trampoline[State, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[State, int]] {
|
||||
if A.IsEmpty(state.list) {
|
||||
return R.Of(TR.Land[State](state.sum))
|
||||
}
|
||||
return R.Of(TR.Bounce[int](State{state.list[1:], state.sum + state.list[0]}))
|
||||
}
|
||||
}
|
||||
|
||||
sumList := TailRec(sumStep)
|
||||
result := sumList(State{[]int{1, 2, 3, 4, 5}, 0})(context.Background())
|
||||
|
||||
assert.Equal(t, R.Of(15), result)
|
||||
}
|
||||
|
||||
// TestTailRecCollatzConjecture tests the Collatz conjecture
|
||||
func TestTailRecCollatzConjecture(t *testing.T) {
|
||||
collatzStep := func(n int) ReaderResult[TR.Trampoline[int, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[int, int]] {
|
||||
if n <= 1 {
|
||||
return R.Of(TR.Land[int](n))
|
||||
}
|
||||
if n%2 == 0 {
|
||||
return R.Of(TR.Bounce[int](n / 2))
|
||||
}
|
||||
return R.Of(TR.Bounce[int](3*n + 1))
|
||||
}
|
||||
}
|
||||
|
||||
collatz := TailRec(collatzStep)
|
||||
result := collatz(10)(context.Background())
|
||||
|
||||
assert.Equal(t, R.Of(1), result)
|
||||
}
|
||||
|
||||
// TestTailRecGCD tests greatest common divisor
|
||||
func TestTailRecGCD(t *testing.T) {
|
||||
type State struct {
|
||||
a int
|
||||
b int
|
||||
}
|
||||
|
||||
gcdStep := func(state State) ReaderResult[TR.Trampoline[State, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[State, int]] {
|
||||
if state.b == 0 {
|
||||
return R.Of(TR.Land[State](state.a))
|
||||
}
|
||||
return R.Of(TR.Bounce[int](State{state.b, state.a % state.b}))
|
||||
}
|
||||
}
|
||||
|
||||
gcd := TailRec(gcdStep)
|
||||
result := gcd(State{48, 18})(context.Background())
|
||||
|
||||
assert.Equal(t, R.Of(6), result)
|
||||
}
|
||||
|
||||
// TestTailRecErrorPropagation tests that errors are properly propagated
|
||||
func TestTailRecErrorPropagation(t *testing.T) {
|
||||
expectedErr := errors.New("computation error")
|
||||
|
||||
errorStep := func(n int) ReaderResult[TR.Trampoline[int, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[int, int]] {
|
||||
if n == 5 {
|
||||
return R.Left[TR.Trampoline[int, int]](expectedErr)
|
||||
}
|
||||
if n <= 0 {
|
||||
return R.Of(TR.Land[int](n))
|
||||
}
|
||||
return R.Of(TR.Bounce[int](n - 1))
|
||||
}
|
||||
}
|
||||
|
||||
computation := TailRec(errorStep)
|
||||
result := computation(10)(context.Background())
|
||||
|
||||
assert.True(t, R.IsLeft(result))
|
||||
_, err := R.Unwrap(result)
|
||||
assert.Equal(t, expectedErr, err)
|
||||
}
|
||||
|
||||
// TestTailRecContextCancellationImmediate tests short circuit when context is already canceled
|
||||
func TestTailRecContextCancellationImmediate(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately before execution
|
||||
|
||||
stepExecuted := false
|
||||
countdownStep := func(n int) ReaderResult[TR.Trampoline[int, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[int, int]] {
|
||||
stepExecuted = true
|
||||
if n <= 0 {
|
||||
return R.Of(TR.Land[int](n))
|
||||
}
|
||||
return R.Of(TR.Bounce[int](n - 1))
|
||||
}
|
||||
}
|
||||
|
||||
countdown := TailRec(countdownStep)
|
||||
result := countdown(10)(ctx)
|
||||
|
||||
// Should short circuit without executing any steps
|
||||
assert.False(t, stepExecuted, "Step should not be executed when context is already canceled")
|
||||
assert.True(t, R.IsLeft(result))
|
||||
_, err := R.Unwrap(result)
|
||||
assert.Equal(t, context.Canceled, err)
|
||||
}
|
||||
|
||||
// TestTailRecContextCancellationDuringExecution tests short circuit when context is canceled during execution
|
||||
func TestTailRecContextCancellationDuringExecution(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
executionCount := 0
|
||||
countdownStep := func(n int) ReaderResult[TR.Trampoline[int, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[int, int]] {
|
||||
executionCount++
|
||||
// Cancel after 3 iterations
|
||||
if executionCount == 3 {
|
||||
cancel()
|
||||
}
|
||||
if n <= 0 {
|
||||
return R.Of(TR.Land[int](n))
|
||||
}
|
||||
return R.Of(TR.Bounce[int](n - 1))
|
||||
}
|
||||
}
|
||||
|
||||
countdown := TailRec(countdownStep)
|
||||
result := countdown(100)(ctx)
|
||||
|
||||
// Should stop after cancellation
|
||||
assert.True(t, R.IsLeft(result))
|
||||
assert.LessOrEqual(t, executionCount, 4, "Should stop shortly after cancellation")
|
||||
_, err := R.Unwrap(result)
|
||||
assert.Equal(t, context.Canceled, err)
|
||||
}
|
||||
|
||||
// TestTailRecContextWithTimeout tests behavior with timeout context
|
||||
func TestTailRecContextWithTimeout(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
executionCount := 0
|
||||
slowStep := func(n int) ReaderResult[TR.Trampoline[int, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[int, int]] {
|
||||
executionCount++
|
||||
// Simulate slow computation
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if n <= 0 {
|
||||
return R.Of(TR.Land[int](n))
|
||||
}
|
||||
return R.Of(TR.Bounce[int](n - 1))
|
||||
}
|
||||
}
|
||||
|
||||
computation := TailRec(slowStep)
|
||||
result := computation(100)(ctx)
|
||||
|
||||
// Should timeout and return error
|
||||
assert.True(t, R.IsLeft(result))
|
||||
assert.Less(t, executionCount, 100, "Should not complete all iterations due to timeout")
|
||||
_, err := R.Unwrap(result)
|
||||
assert.Equal(t, context.DeadlineExceeded, err)
|
||||
}
|
||||
|
||||
// TestTailRecContextWithCause tests that context.Cause is properly returned
|
||||
func TestTailRecContextWithCause(t *testing.T) {
|
||||
customErr := errors.New("custom cancellation reason")
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
cancel(customErr)
|
||||
|
||||
countdownStep := func(n int) ReaderResult[TR.Trampoline[int, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[int, int]] {
|
||||
if n <= 0 {
|
||||
return R.Of(TR.Land[int](n))
|
||||
}
|
||||
return R.Of(TR.Bounce[int](n - 1))
|
||||
}
|
||||
}
|
||||
|
||||
countdown := TailRec(countdownStep)
|
||||
result := countdown(10)(ctx)
|
||||
|
||||
assert.True(t, R.IsLeft(result))
|
||||
_, err := R.Unwrap(result)
|
||||
assert.Equal(t, customErr, err)
|
||||
}
|
||||
|
||||
// TestTailRecContextCancellationMultipleIterations tests that cancellation is checked on each iteration
|
||||
func TestTailRecContextCancellationMultipleIterations(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
executionCount := 0
|
||||
maxExecutions := 5
|
||||
|
||||
countdownStep := func(n int) ReaderResult[TR.Trampoline[int, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[int, int]] {
|
||||
executionCount++
|
||||
if executionCount == maxExecutions {
|
||||
cancel()
|
||||
}
|
||||
if n <= 0 {
|
||||
return R.Of(TR.Land[int](n))
|
||||
}
|
||||
return R.Of(TR.Bounce[int](n - 1))
|
||||
}
|
||||
}
|
||||
|
||||
countdown := TailRec(countdownStep)
|
||||
result := countdown(1000)(ctx)
|
||||
|
||||
// Should detect cancellation on next iteration check
|
||||
assert.True(t, R.IsLeft(result))
|
||||
// Should stop within 1-2 iterations after cancellation
|
||||
assert.LessOrEqual(t, executionCount, maxExecutions+2)
|
||||
_, err := R.Unwrap(result)
|
||||
assert.Equal(t, context.Canceled, err)
|
||||
}
|
||||
|
||||
// TestTailRecContextNotCanceled tests normal execution when context is not canceled
|
||||
func TestTailRecContextNotCanceled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
executionCount := 0
|
||||
countdownStep := func(n int) ReaderResult[TR.Trampoline[int, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[int, int]] {
|
||||
executionCount++
|
||||
if n <= 0 {
|
||||
return R.Of(TR.Land[int](n))
|
||||
}
|
||||
return R.Of(TR.Bounce[int](n - 1))
|
||||
}
|
||||
}
|
||||
|
||||
countdown := TailRec(countdownStep)
|
||||
result := countdown(10)(ctx)
|
||||
|
||||
assert.Equal(t, 11, executionCount) // 10, 9, 8, ..., 1, 0
|
||||
assert.Equal(t, R.Of(0), result)
|
||||
}
|
||||
|
||||
// TestTailRecPowerOfTwo tests computing power of 2
|
||||
func TestTailRecPowerOfTwo(t *testing.T) {
|
||||
type State struct {
|
||||
exponent int
|
||||
result int
|
||||
target int
|
||||
}
|
||||
|
||||
powerStep := func(state State) ReaderResult[TR.Trampoline[State, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[State, int]] {
|
||||
if state.exponent >= state.target {
|
||||
return R.Of(TR.Land[State](state.result))
|
||||
}
|
||||
return R.Of(TR.Bounce[int](State{state.exponent + 1, state.result * 2, state.target}))
|
||||
}
|
||||
}
|
||||
|
||||
power := TailRec(powerStep)
|
||||
result := power(State{0, 1, 10})(context.Background())
|
||||
|
||||
assert.Equal(t, R.Of(1024), result) // 2^10
|
||||
}
|
||||
|
||||
// TestTailRecFindInRange tests finding a value in a range
|
||||
func TestTailRecFindInRange(t *testing.T) {
|
||||
type State struct {
|
||||
current int
|
||||
max int
|
||||
target int
|
||||
}
|
||||
|
||||
findStep := func(state State) ReaderResult[TR.Trampoline[State, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[State, int]] {
|
||||
if state.current >= state.max {
|
||||
return R.Of(TR.Land[State](-1)) // Not found
|
||||
}
|
||||
if state.current == state.target {
|
||||
return R.Of(TR.Land[State](state.current)) // Found
|
||||
}
|
||||
return R.Of(TR.Bounce[int](State{state.current + 1, state.max, state.target}))
|
||||
}
|
||||
}
|
||||
|
||||
find := TailRec(findStep)
|
||||
result := find(State{0, 100, 42})(context.Background())
|
||||
|
||||
assert.Equal(t, R.Of(42), result)
|
||||
}
|
||||
|
||||
// TestTailRecFindNotInRange tests finding a value not in range
|
||||
func TestTailRecFindNotInRange(t *testing.T) {
|
||||
type State struct {
|
||||
current int
|
||||
max int
|
||||
target int
|
||||
}
|
||||
|
||||
findStep := func(state State) ReaderResult[TR.Trampoline[State, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[State, int]] {
|
||||
if state.current >= state.max {
|
||||
return R.Of(TR.Land[State](-1)) // Not found
|
||||
}
|
||||
if state.current == state.target {
|
||||
return R.Of(TR.Land[State](state.current)) // Found
|
||||
}
|
||||
return R.Of(TR.Bounce[int](State{state.current + 1, state.max, state.target}))
|
||||
}
|
||||
}
|
||||
|
||||
find := TailRec(findStep)
|
||||
result := find(State{0, 100, 200})(context.Background())
|
||||
|
||||
assert.Equal(t, R.Of(-1), result)
|
||||
}
|
||||
|
||||
// TestTailRecWithContextValue tests that context values are accessible
|
||||
func TestTailRecWithContextValue(t *testing.T) {
|
||||
type contextKey string
|
||||
const multiplierKey contextKey = "multiplier"
|
||||
|
||||
ctx := context.WithValue(context.Background(), multiplierKey, 3)
|
||||
|
||||
countdownStep := func(n int) ReaderResult[TR.Trampoline[int, int]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[int, int]] {
|
||||
if n <= 0 {
|
||||
multiplier := ctx.Value(multiplierKey).(int)
|
||||
return R.Of(TR.Land[int](n * multiplier))
|
||||
}
|
||||
return R.Of(TR.Bounce[int](n - 1))
|
||||
}
|
||||
}
|
||||
|
||||
countdown := TailRec(countdownStep)
|
||||
result := countdown(5)(ctx)
|
||||
|
||||
assert.Equal(t, R.Of(0), result) // 0 * 3 = 0
|
||||
}
|
||||
|
||||
// TestTailRecComplexState tests with complex state structure
|
||||
func TestTailRecComplexState(t *testing.T) {
|
||||
type ComplexState struct {
|
||||
counter int
|
||||
sum int
|
||||
product int
|
||||
completed bool
|
||||
}
|
||||
|
||||
complexStep := func(state ComplexState) ReaderResult[TR.Trampoline[ComplexState, string]] {
|
||||
return func(ctx context.Context) Result[TR.Trampoline[ComplexState, string]] {
|
||||
if state.counter <= 0 || state.completed {
|
||||
result := fmt.Sprintf("sum=%d, product=%d", state.sum, state.product)
|
||||
return R.Of(TR.Land[ComplexState](result))
|
||||
}
|
||||
newState := ComplexState{
|
||||
counter: state.counter - 1,
|
||||
sum: state.sum + state.counter,
|
||||
product: state.product * state.counter,
|
||||
completed: state.counter == 1,
|
||||
}
|
||||
return R.Of(TR.Bounce[string](newState))
|
||||
}
|
||||
}
|
||||
|
||||
computation := TailRec(complexStep)
|
||||
result := computation(ComplexState{5, 0, 1, false})(context.Background())
|
||||
|
||||
assert.Equal(t, R.Of("sum=15, product=120"), result)
|
||||
}
|
||||
156
v2/context/readerresult/retry.go
Normal file
156
v2/context/readerresult/retry.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache LicensVersion 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package readerresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
RD "github.com/IBM/fp-go/v2/reader"
|
||||
R "github.com/IBM/fp-go/v2/retry"
|
||||
RG "github.com/IBM/fp-go/v2/retry/generic"
|
||||
)
|
||||
|
||||
// Retrying retries a ReaderResult computation according to a retry policy with context awareness.
|
||||
//
|
||||
// This function implements a retry mechanism for operations that depend on a [context.Context]
|
||||
// and can fail (Result). It respects context cancellation, meaning that if the context is
|
||||
// cancelled during retry delays, the operation will stop immediately and return the cancellation error.
|
||||
//
|
||||
// The retry loop will continue until one of the following occurs:
|
||||
// - The action succeeds and the check function returns false (no retry needed)
|
||||
// - The retry policy returns None (retry limit reached)
|
||||
// - The check function returns false (indicating success or a non-retryable failure)
|
||||
// - The context is cancelled (returns context.Canceled or context.DeadlineExceeded)
|
||||
//
|
||||
// Type Parameters:
|
||||
// - A: The type of the success value
|
||||
//
|
||||
// Parameters:
|
||||
//
|
||||
// - policy: A RetryPolicy that determines when and how long to wait between retries.
|
||||
// The policy receives a RetryStatus on each iteration and returns an optional delay.
|
||||
// If it returns None, retrying stops. Common policies include LimitRetries,
|
||||
// ExponentialBackoff, and CapDelay from the retry package.
|
||||
//
|
||||
// - action: A Kleisli arrow that takes a RetryStatus and returns a ReaderResult[A].
|
||||
// This function is called on each retry attempt and receives information about the
|
||||
// current retry state (iteration number, cumulative delay, etc.). The action depends
|
||||
// on a context.Context and produces a Result[A].
|
||||
//
|
||||
// - check: A predicate function that examines the Result[A] and returns true if the
|
||||
// operation should be retried, or false if it should stop. This allows you to
|
||||
// distinguish between retryable failures (e.g., network timeouts) and permanent
|
||||
// failures (e.g., invalid input).
|
||||
//
|
||||
// Returns:
|
||||
// - A ReaderResult[A] that, when executed with a context, will perform the retry
|
||||
// logic with context cancellation support and return the final result.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Create a retry policy: exponential backoff with a cap, limited to 5 retries
|
||||
// policy := M.Concat(
|
||||
// retry.LimitRetries(5),
|
||||
// retry.CapDelay(10*time.Second, retry.ExponentialBackoff(100*time.Millisecond)),
|
||||
// )(retry.Monoid)
|
||||
//
|
||||
// // Action that fetches data
|
||||
// fetchData := func(status retry.RetryStatus) ReaderResult[string] {
|
||||
// return func(ctx context.Context) Result[string] {
|
||||
// if ctx.Err() != nil {
|
||||
// return result.Left[string](ctx.Err())
|
||||
// }
|
||||
// if status.IterNumber < 3 {
|
||||
// return result.Left[string](fmt.Errorf("temporary error"))
|
||||
// }
|
||||
// return result.Of("success")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Check function: retry on any error except context cancellation
|
||||
// shouldRetry := func(r Result[string]) bool {
|
||||
// return result.IsLeft(r) && !errors.Is(result.GetLeft(r), context.Canceled)
|
||||
// }
|
||||
//
|
||||
// // Create the retrying computation
|
||||
// retryingFetch := Retrying(policy, fetchData, shouldRetry)
|
||||
//
|
||||
// // Execute with a cancellable context
|
||||
// ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
// defer cancel()
|
||||
// finalResult := retryingFetch(ctx)
|
||||
//
|
||||
//go:inline
|
||||
func Retrying[A any](
|
||||
policy R.RetryPolicy,
|
||||
action Kleisli[R.RetryStatus, A],
|
||||
check Predicate[Result[A]],
|
||||
) ReaderResult[A] {
|
||||
|
||||
// delayWithCancel implements a context-aware delay mechanism for retry operations.
|
||||
// It creates a timeout context that will be cancelled when either:
|
||||
// 1. The delay duration expires (normal case), or
|
||||
// 2. The parent context is cancelled (early termination)
|
||||
//
|
||||
// The function waits on timeoutCtx.Done(), which will be signaled in either case:
|
||||
// - If the delay expires, timeoutCtx is cancelled by the timeout
|
||||
// - If the parent ctx is cancelled, timeoutCtx inherits the cancellation
|
||||
//
|
||||
// After the wait completes, we dispatch to the next action by calling ri(ctx)().
|
||||
// This works correctly because the action is wrapped in WithContextK, which handles
|
||||
// context cancellation by checking ctx.Err() and returning an appropriate error
|
||||
// (context.Canceled or context.DeadlineExceeded) when the context is cancelled.
|
||||
//
|
||||
// This design ensures that:
|
||||
// - Retry delays respect context cancellation and terminate immediately
|
||||
// - The cancellation error propagates correctly through the retry chain
|
||||
// - No unnecessary delays occur when the context is already cancelled
|
||||
delayWithCancel := func(delay time.Duration) RD.Operator[context.Context, R.RetryStatus, R.RetryStatus] {
|
||||
return func(ri Reader[context.Context, R.RetryStatus]) Reader[context.Context, R.RetryStatus] {
|
||||
return func(ctx context.Context) R.RetryStatus {
|
||||
// Create a timeout context that will be cancelled when either:
|
||||
// - The delay duration expires, or
|
||||
// - The parent context is cancelled
|
||||
timeoutCtx, cancelTimeout := context.WithTimeout(ctx, delay)
|
||||
defer cancelTimeout()
|
||||
|
||||
// Wait for either the timeout or parent context cancellation
|
||||
<-timeoutCtx.Done()
|
||||
|
||||
// Dispatch to the next action with the original context.
|
||||
// WithContextK will handle context cancellation correctly.
|
||||
return ri(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// get an implementation for the types
|
||||
return RG.Retrying(
|
||||
RD.Chain[context.Context, Result[A], Trampoline[R.RetryStatus, Result[A]]],
|
||||
RD.Map[context.Context, R.RetryStatus, Trampoline[R.RetryStatus, Result[A]]],
|
||||
RD.Of[context.Context, Trampoline[R.RetryStatus, Result[A]]],
|
||||
RD.Of[context.Context, R.RetryStatus],
|
||||
delayWithCancel,
|
||||
|
||||
RD.TailRec,
|
||||
|
||||
policy,
|
||||
WithContextK(action),
|
||||
check,
|
||||
)
|
||||
|
||||
}
|
||||
@@ -13,17 +13,46 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// package readerresult implements a specialization of the Reader monad assuming a golang context as the context of the monad and a standard golang error
|
||||
// Package readerresult implements a specialization of the Reader monad assuming a golang context as the context of the monad and a standard golang error.
|
||||
//
|
||||
// # Pure vs Effectful Functions
|
||||
//
|
||||
// This package distinguishes between pure (side-effect free) and effectful (side-effectful) functions:
|
||||
//
|
||||
// EFFECTFUL FUNCTIONS (depend on context.Context):
|
||||
// - ReaderResult[A]: func(context.Context) (A, error) - Effectful computation that needs context
|
||||
// - These functions are effectful because context.Context is effectful (can be cancelled, has deadlines, carries values)
|
||||
// - Use for: operations that need cancellation, timeouts, context values, or any context-dependent behavior
|
||||
// - Examples: database queries, HTTP requests, operations that respect cancellation
|
||||
//
|
||||
// PURE FUNCTIONS (side-effect free):
|
||||
// - func(State) (Value, error) - Pure computation that only depends on state, not context
|
||||
// - func(State) Value - Pure transformation without errors
|
||||
// - These functions are pure because they only read from their input state and don't depend on external context
|
||||
// - Use for: parsing, validation, calculations, data transformations that don't need context
|
||||
// - Examples: JSON parsing, input validation, mathematical computations
|
||||
//
|
||||
// The package provides different bind operations for each:
|
||||
// - Bind: For effectful ReaderResult computations (State -> ReaderResult[Value])
|
||||
// - BindResultK: For pure functions with errors (State -> (Value, error))
|
||||
// - Let: For pure functions without errors (State -> Value)
|
||||
// - BindReaderK: For context-dependent pure functions (State -> Reader[Context, Value])
|
||||
// - BindEitherK: For pure Result/Either values (State -> Result[Value])
|
||||
package readerresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/IBM/fp-go/v2/either"
|
||||
"github.com/IBM/fp-go/v2/endomorphism"
|
||||
"github.com/IBM/fp-go/v2/optics/lens"
|
||||
"github.com/IBM/fp-go/v2/optics/prism"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/predicate"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
"github.com/IBM/fp-go/v2/readereither"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
"github.com/IBM/fp-go/v2/tailrec"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -34,6 +63,11 @@ type (
|
||||
// ReaderResult is a specialization of the Reader monad for the typical golang scenario
|
||||
ReaderResult[A any] = readereither.ReaderEither[context.Context, error, A]
|
||||
|
||||
Kleisli[A, B any] = reader.Reader[A, ReaderResult[B]]
|
||||
Operator[A, B any] = Kleisli[ReaderResult[A], B]
|
||||
Kleisli[A, B any] = reader.Reader[A, ReaderResult[B]]
|
||||
Operator[A, B any] = Kleisli[ReaderResult[A], B]
|
||||
Endomorphism[A any] = endomorphism.Endomorphism[A]
|
||||
Prism[S, T any] = prism.Prism[S, T]
|
||||
Lens[S, T any] = lens.Lens[S, T]
|
||||
Trampoline[A, B any] = tailrec.Trampoline[A, B]
|
||||
Predicate[A any] = predicate.Predicate[A]
|
||||
)
|
||||
|
||||
55
v2/context/statereaderioresult/filter.go
Normal file
55
v2/context/statereaderioresult/filter.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2024 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package statereaderioresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/IBM/fp-go/v2/statereaderioeither"
|
||||
)
|
||||
|
||||
// FilterOrElse filters a StateReaderIOResult value based on a predicate.
|
||||
// This is a convenience wrapper around statereaderioeither.FilterOrElse that fixes
|
||||
// the context type to context.Context and the error type to error.
|
||||
//
|
||||
// If the predicate returns true for the Right value, it passes through unchanged.
|
||||
// If the predicate returns false, it transforms the Right value into a Left (error) using onFalse.
|
||||
// Left values are passed through unchanged.
|
||||
//
|
||||
// Parameters:
|
||||
// - pred: A predicate function that tests the Right value
|
||||
// - onFalse: A function that converts the failing value into an error
|
||||
//
|
||||
// Returns:
|
||||
// - An Operator that filters StateReaderIOResult values based on the predicate
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type AppState struct {
|
||||
// Counter int
|
||||
// }
|
||||
//
|
||||
// // Validate that a number is positive
|
||||
// isPositive := N.MoreThan(0)
|
||||
// onNegative := func(n int) error { return fmt.Errorf("%d is not positive", n) }
|
||||
//
|
||||
// filter := statereaderioresult.FilterOrElse[AppState](isPositive, onNegative)
|
||||
// result := filter(statereaderioresult.Right[AppState](42))(AppState{})(context.Background())()
|
||||
//
|
||||
//go:inline
|
||||
func FilterOrElse[S, A any](pred Predicate[A], onFalse func(A) error) Operator[S, A, A] {
|
||||
return statereaderioeither.FilterOrElse[S, context.Context](pred, onFalse)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/IBM/fp-go/v2/ioresult"
|
||||
"github.com/IBM/fp-go/v2/optics/iso/lens"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
"github.com/IBM/fp-go/v2/predicate"
|
||||
"github.com/IBM/fp-go/v2/reader"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
"github.com/IBM/fp-go/v2/state"
|
||||
@@ -81,4 +82,6 @@ type (
|
||||
// Operator represents a function that transforms one StateReaderIOResult into another.
|
||||
// This is commonly used for building composable operations via Map, Chain, etc.
|
||||
Operator[S, A, B any] = Reader[StateReaderIOResult[S, A], StateReaderIOResult[S, B]]
|
||||
|
||||
Predicate[A any] = predicate.Predicate[A]
|
||||
)
|
||||
|
||||
9917
v2/coverage.out
9917
v2/coverage.out
File diff suppressed because it is too large
Load Diff
@@ -23,14 +23,15 @@ import (
|
||||
IOR "github.com/IBM/fp-go/v2/ioresult"
|
||||
L "github.com/IBM/fp-go/v2/lazy"
|
||||
O "github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/pair"
|
||||
R "github.com/IBM/fp-go/v2/record"
|
||||
T "github.com/IBM/fp-go/v2/tuple"
|
||||
|
||||
"sync"
|
||||
)
|
||||
|
||||
func providerToEntry(p Provider) T.Tuple2[string, ProviderFactory] {
|
||||
return T.MakeTuple2(p.Provides().Id(), p.Factory())
|
||||
func providerToEntry(p Provider) Entry[string, ProviderFactory] {
|
||||
return pair.MakePair(p.Provides().Id(), p.Factory())
|
||||
}
|
||||
|
||||
func itemProviderToMap(p Provider) map[string][]ProviderFactory {
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"github.com/IBM/fp-go/v2/iooption"
|
||||
"github.com/IBM/fp-go/v2/ioresult"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/record"
|
||||
)
|
||||
|
||||
type (
|
||||
Option[T any] = option.Option[T]
|
||||
IOResult[T any] = ioresult.IOResult[T]
|
||||
IOOption[T any] = iooption.IOOption[T]
|
||||
Option[T any] = option.Option[T]
|
||||
IOResult[T any] = ioresult.IOResult[T]
|
||||
IOOption[T any] = iooption.IOOption[T]
|
||||
Entry[K comparable, V any] = record.Entry[K, V]
|
||||
)
|
||||
|
||||
@@ -103,11 +103,11 @@ func (t *token[T]) Unerase(val any) Result[T] {
|
||||
func (t *token[T]) ProviderFactory() Option[DIE.ProviderFactory] {
|
||||
return t.base.providerFactory
|
||||
}
|
||||
func makeTokenBase(name string, id string, typ int, providerFactory Option[DIE.ProviderFactory]) *tokenBase {
|
||||
func makeTokenBase(name, id string, typ int, providerFactory Option[DIE.ProviderFactory]) *tokenBase {
|
||||
return &tokenBase{name, id, typ, providerFactory}
|
||||
}
|
||||
|
||||
func makeToken[T any](name string, id string, typ int, unerase func(val any) Result[T], providerFactory Option[DIE.ProviderFactory]) Dependency[T] {
|
||||
func makeToken[T any](name, id string, typ int, unerase func(val any) Result[T], providerFactory Option[DIE.ProviderFactory]) Dependency[T] {
|
||||
return &token[T]{makeTokenBase(name, id, typ, providerFactory), unerase}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,23 @@ import (
|
||||
"github.com/IBM/fp-go/v2/context/ioresult"
|
||||
"github.com/IBM/fp-go/v2/iooption"
|
||||
"github.com/IBM/fp-go/v2/option"
|
||||
"github.com/IBM/fp-go/v2/record"
|
||||
"github.com/IBM/fp-go/v2/result"
|
||||
)
|
||||
|
||||
type (
|
||||
Option[T any] = option.Option[T]
|
||||
Result[T any] = result.Result[T]
|
||||
// Option represents an optional value that may or may not be present.
|
||||
Option[T any] = option.Option[T]
|
||||
|
||||
// Result represents a computation that may fail with an error.
|
||||
Result[T any] = result.Result[T]
|
||||
|
||||
// IOResult represents a synchronous computation that may fail with an error.
|
||||
IOResult[T any] = ioresult.IOResult[T]
|
||||
|
||||
// IOOption represents a synchronous computation that may not produce a value.
|
||||
IOOption[T any] = iooption.IOOption[T]
|
||||
|
||||
// Entry represents a key-value pair in a record/map structure.
|
||||
Entry[K comparable, V any] = record.Entry[K, V]
|
||||
)
|
||||
|
||||
@@ -75,7 +75,7 @@ func TraverseArray[E, A, B any](f Kleisli[E, A, B]) Kleisli[E, []A, []B] {
|
||||
// Example:
|
||||
//
|
||||
// validate := func(i int, s string) either.Either[error, string] {
|
||||
// if len(s) > 0 {
|
||||
// if S.IsNonEmpty(s) {
|
||||
// return either.Right[error](fmt.Sprintf("%d:%s", i, s))
|
||||
// }
|
||||
// return either.Left[string](fmt.Errorf("empty at index %d", i))
|
||||
@@ -105,7 +105,7 @@ func TraverseArrayWithIndexG[GA ~[]A, GB ~[]B, E, A, B any](f func(int, A) Eithe
|
||||
// Example:
|
||||
//
|
||||
// validate := func(i int, s string) either.Either[error, string] {
|
||||
// if len(s) > 0 {
|
||||
// if S.IsNonEmpty(s) {
|
||||
// return either.Right[error](fmt.Sprintf("%d:%s", i, s))
|
||||
// }
|
||||
// return either.Left[string](fmt.Errorf("empty at index %d", i))
|
||||
|
||||
@@ -15,10 +15,6 @@
|
||||
|
||||
package either
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type (
|
||||
// Either defines a data structure that logically holds either an E or an A. The flag discriminates the cases
|
||||
Either[E, A any] struct {
|
||||
@@ -28,28 +24,6 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
// String prints some debug info for the object
|
||||
//
|
||||
//go:noinline
|
||||
func (s Either[E, A]) String() string {
|
||||
if !s.isLeft {
|
||||
return fmt.Sprintf("Right[%T](%v)", s.r, s.r)
|
||||
}
|
||||
return fmt.Sprintf("Left[%T](%v)", s.l, s.l)
|
||||
}
|
||||
|
||||
// Format prints some debug info for the object
|
||||
//
|
||||
//go:noinline
|
||||
func (s Either[E, A]) Format(f fmt.State, c rune) {
|
||||
switch c {
|
||||
case 's':
|
||||
fmt.Fprint(f, s.String())
|
||||
default:
|
||||
fmt.Fprint(f, s.String())
|
||||
}
|
||||
}
|
||||
|
||||
// IsLeft tests if the Either is a Left value.
|
||||
// Rather use [Fold] or [MonadFold] if you need to access the values.
|
||||
// Inverse is [IsRight].
|
||||
|
||||
@@ -34,7 +34,7 @@ func Curry0[R any](f func() (R, error)) func() Either[error, R] {
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// parse := func(s string) (int, error) { return strconv.Atoi(s) }
|
||||
// parse := strconv.Atoi
|
||||
// curried := either.Curry1(parse)
|
||||
// result := curried("42") // Right(42)
|
||||
func Curry1[T1, R any](f func(T1) (R, error)) func(T1) Either[error, R] {
|
||||
|
||||
@@ -19,6 +19,21 @@
|
||||
// - Left represents an error or failure case (type E)
|
||||
// - Right represents a success case (type A)
|
||||
//
|
||||
// # Fantasy Land Specification
|
||||
//
|
||||
// This implementation corresponds to the Fantasy Land Either type:
|
||||
// https://github.com/fantasyland/fantasy-land#either
|
||||
//
|
||||
// Implemented Fantasy Land algebras:
|
||||
// - Functor: https://github.com/fantasyland/fantasy-land#functor
|
||||
// - Bifunctor: https://github.com/fantasyland/fantasy-land#bifunctor
|
||||
// - Apply: https://github.com/fantasyland/fantasy-land#apply
|
||||
// - Applicative: https://github.com/fantasyland/fantasy-land#applicative
|
||||
// - Chain: https://github.com/fantasyland/fantasy-land#chain
|
||||
// - Monad: https://github.com/fantasyland/fantasy-land#monad
|
||||
// - Alt: https://github.com/fantasyland/fantasy-land#alt
|
||||
// - Foldable: https://github.com/fantasyland/fantasy-land#foldable
|
||||
//
|
||||
// # Core Concepts
|
||||
//
|
||||
// The Either type is a discriminated union that can hold either a Left value (typically an error)
|
||||
|
||||
@@ -394,12 +394,12 @@ func UnwrapError[A any](ma Either[error, A]) (A, error) {
|
||||
// Example:
|
||||
//
|
||||
// isPositive := either.FromPredicate(
|
||||
// func(x int) bool { return x > 0 },
|
||||
// N.MoreThan(0),
|
||||
// func(x int) error { return errors.New("not positive") },
|
||||
// )
|
||||
// result := isPositive(42) // Right(42)
|
||||
// result := isPositive(-1) // Left(error)
|
||||
func FromPredicate[E, A any](pred func(A) bool, onFalse func(A) E) func(A) Either[E, A] {
|
||||
func FromPredicate[E, A any](pred Predicate[A], onFalse func(A) E) Kleisli[E, A, A] {
|
||||
return func(a A) Either[E, A] {
|
||||
if pred(a) {
|
||||
return Right[E](a)
|
||||
@@ -416,7 +416,7 @@ func FromPredicate[E, A any](pred func(A) bool, onFalse func(A) E) func(A) Eithe
|
||||
// result := either.FromNillable[int](errors.New("nil"))(ptr) // Left(error)
|
||||
// val := 42
|
||||
// result := either.FromNillable[int](errors.New("nil"))(&val) // Right(&42)
|
||||
func FromNillable[A, E any](e E) func(*A) Either[E, *A] {
|
||||
func FromNillable[A, E any](e E) Kleisli[E, *A, *A] {
|
||||
return FromPredicate(F.IsNonNil[A], F.Constant1[*A](e))
|
||||
}
|
||||
|
||||
@@ -450,7 +450,7 @@ func Reduce[E, A, B any](f func(B, A) B, initial B) func(Either[E, A]) B {
|
||||
// return either.Right[string](99)
|
||||
// })
|
||||
// result := alternative(either.Left[int](errors.New("fail"))) // Right(99)
|
||||
func AltW[E, E1, A any](that Lazy[Either[E1, A]]) func(Either[E, A]) Either[E1, A] {
|
||||
func AltW[E, E1, A any](that Lazy[Either[E1, A]]) Kleisli[E1, Either[E, A], A] {
|
||||
return Fold(F.Ignore1of1[E](that), Right[E1, A])
|
||||
}
|
||||
|
||||
@@ -466,16 +466,29 @@ func Alt[E, A any](that Lazy[Either[E, A]]) Operator[E, A, A] {
|
||||
return AltW[E](that)
|
||||
}
|
||||
|
||||
// OrElse recovers from a Left by providing an alternative computation.
|
||||
// OrElse recovers from a Left (error) by providing an alternative computation.
|
||||
// If the Either is Right, it returns the value unchanged.
|
||||
// If the Either is Left, it applies the provided function to the error value,
|
||||
// which returns a new Either that replaces the original.
|
||||
//
|
||||
// This is useful for error recovery, fallback logic, or chaining alternative computations.
|
||||
// The error type can be widened from E1 to E2, allowing transformation of error types.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// // Recover from specific errors with fallback values
|
||||
// recover := either.OrElse(func(err error) either.Either[error, int] {
|
||||
// return either.Right[error](0) // default value
|
||||
// if err.Error() == "not found" {
|
||||
// return either.Right[error](0) // default value
|
||||
// }
|
||||
// return either.Left[int](err) // propagate other errors
|
||||
// })
|
||||
// result := recover(either.Left[int](errors.New("fail"))) // Right(0)
|
||||
func OrElse[E, A any](onLeft Kleisli[E, E, A]) Operator[E, A, A] {
|
||||
return Fold(onLeft, Of[E, A])
|
||||
// result := recover(either.Left[int](errors.New("not found"))) // Right(0)
|
||||
// result := recover(either.Right[error](42)) // Right(42) - unchanged
|
||||
//
|
||||
//go:inline
|
||||
func OrElse[E1, E2, A any](onLeft Kleisli[E2, E1, A]) Kleisli[E2, Either[E1, A], A] {
|
||||
return Fold(onLeft, Of[E2, A])
|
||||
}
|
||||
|
||||
// ToType attempts to convert an any value to a specific type, returning Either.
|
||||
|
||||
@@ -22,8 +22,9 @@ import (
|
||||
"testing"
|
||||
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
M "github.com/IBM/fp-go/v2/monoid"
|
||||
N "github.com/IBM/fp-go/v2/number"
|
||||
O "github.com/IBM/fp-go/v2/option"
|
||||
S "github.com/IBM/fp-go/v2/string"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -159,6 +160,7 @@ func TestToError(t *testing.T) {
|
||||
|
||||
// Test OrElse
|
||||
func TestOrElse(t *testing.T) {
|
||||
// Test basic recovery from Left
|
||||
recover := OrElse(func(e error) Either[error, int] {
|
||||
return Right[error](0)
|
||||
})
|
||||
@@ -166,8 +168,85 @@ func TestOrElse(t *testing.T) {
|
||||
result := recover(Left[int](errors.New("error")))
|
||||
assert.Equal(t, Right[error](0), result)
|
||||
|
||||
// Test Right value passes through unchanged
|
||||
result = recover(Right[error](42))
|
||||
assert.Equal(t, Right[error](42), result)
|
||||
|
||||
// Test selective recovery - recover some errors, propagate others
|
||||
selectiveRecover := OrElse(func(err error) Either[error, int] {
|
||||
if err.Error() == "not found" {
|
||||
return Right[error](0) // default value for "not found"
|
||||
}
|
||||
return Left[int](err) // propagate other errors
|
||||
})
|
||||
assert.Equal(t, Right[error](0), selectiveRecover(Left[int](errors.New("not found"))))
|
||||
permissionErr := errors.New("permission denied")
|
||||
assert.Equal(t, Left[int](permissionErr), selectiveRecover(Left[int](permissionErr)))
|
||||
|
||||
// Test chaining multiple OrElse operations
|
||||
firstRecover := OrElse(func(err error) Either[error, int] {
|
||||
if err.Error() == "error1" {
|
||||
return Right[error](1)
|
||||
}
|
||||
return Left[int](err)
|
||||
})
|
||||
secondRecover := OrElse(func(err error) Either[error, int] {
|
||||
if err.Error() == "error2" {
|
||||
return Right[error](2)
|
||||
}
|
||||
return Left[int](err)
|
||||
})
|
||||
assert.Equal(t, Right[error](1), F.Pipe1(Left[int](errors.New("error1")), firstRecover))
|
||||
assert.Equal(t, Right[error](2), F.Pipe1(Left[int](errors.New("error2")), F.Flow2(firstRecover, secondRecover)))
|
||||
}
|
||||
|
||||
// Test OrElseW
|
||||
func TestOrElseW(t *testing.T) {
|
||||
type ValidationError string
|
||||
type AppError int
|
||||
|
||||
// Test with Right value - should return Right with widened error type
|
||||
rightValue := Right[ValidationError]("success")
|
||||
recoverValidation := OrElse(func(ve ValidationError) Either[AppError, string] {
|
||||
return Left[string](AppError(400))
|
||||
})
|
||||
result := recoverValidation(rightValue)
|
||||
assert.True(t, IsRight(result))
|
||||
assert.Equal(t, "success", F.Pipe1(result, GetOrElse(F.Constant1[AppError](""))))
|
||||
|
||||
// Test with Left value - should apply recovery with new error type
|
||||
leftValue := Left[string](ValidationError("invalid input"))
|
||||
result = recoverValidation(leftValue)
|
||||
assert.True(t, IsLeft(result))
|
||||
_, leftVal := Unwrap(result)
|
||||
assert.Equal(t, AppError(400), leftVal)
|
||||
|
||||
// Test error type conversion - ValidationError to AppError
|
||||
convertError := OrElse(func(ve ValidationError) Either[AppError, int] {
|
||||
return Left[int](AppError(len(ve)))
|
||||
})
|
||||
converted := convertError(Left[int](ValidationError("short")))
|
||||
assert.True(t, IsLeft(converted))
|
||||
_, leftConv := Unwrap(converted)
|
||||
assert.Equal(t, AppError(5), leftConv)
|
||||
|
||||
// Test recovery to Right with widened error type
|
||||
recoverToRight := OrElse(func(ve ValidationError) Either[AppError, int] {
|
||||
if ve == "recoverable" {
|
||||
return Right[AppError](99)
|
||||
}
|
||||
return Left[int](AppError(500))
|
||||
})
|
||||
assert.Equal(t, Right[AppError](99), recoverToRight(Left[int](ValidationError("recoverable"))))
|
||||
assert.True(t, IsLeft(recoverToRight(Left[int](ValidationError("fatal")))))
|
||||
|
||||
// Test that Right values are preserved with widened error type
|
||||
preservedRight := Right[ValidationError](42)
|
||||
preserveRecover := OrElse(func(ve ValidationError) Either[AppError, int] {
|
||||
return Left[int](AppError(999))
|
||||
})
|
||||
preserved := preserveRecover(preservedRight)
|
||||
assert.Equal(t, Right[AppError](42), preserved)
|
||||
}
|
||||
|
||||
// Test ToType
|
||||
@@ -305,7 +384,7 @@ func TestTraverseArray(t *testing.T) {
|
||||
// Test TraverseArrayWithIndex
|
||||
func TestTraverseArrayWithIndex(t *testing.T) {
|
||||
validate := func(i int, s string) Either[error, string] {
|
||||
if len(s) > 0 {
|
||||
if S.IsNonEmpty(s) {
|
||||
return Right[error](fmt.Sprintf("%d:%s", i, s))
|
||||
}
|
||||
return Left[string](fmt.Errorf("empty at index %d", i))
|
||||
@@ -334,7 +413,7 @@ func TestTraverseRecord(t *testing.T) {
|
||||
// Test TraverseRecordWithIndex
|
||||
func TestTraverseRecordWithIndex(t *testing.T) {
|
||||
validate := func(k string, v string) Either[error, string] {
|
||||
if len(v) > 0 {
|
||||
if S.IsNonEmpty(v) {
|
||||
return Right[error](k + ":" + v)
|
||||
}
|
||||
return Left[string](fmt.Errorf("empty value for key %s", k))
|
||||
@@ -373,7 +452,7 @@ func TestCurry0(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCurry1(t *testing.T) {
|
||||
parse := func(s string) (int, error) { return strconv.Atoi(s) }
|
||||
parse := strconv.Atoi
|
||||
curried := Curry1(parse)
|
||||
result := curried("42")
|
||||
assert.Equal(t, Right[error](42), result)
|
||||
@@ -645,7 +724,7 @@ func TestAltSemigroup(t *testing.T) {
|
||||
|
||||
// Test AlternativeMonoid
|
||||
func TestAlternativeMonoid(t *testing.T) {
|
||||
intAdd := M.MakeMonoid(func(a, b int) int { return a + b }, 0)
|
||||
intAdd := N.MonoidSum[int]()
|
||||
m := AlternativeMonoid[error](intAdd)
|
||||
|
||||
result := m.Concat(Right[error](1), Right[error](2))
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
"github.com/IBM/fp-go/v2/internal/utils"
|
||||
IO "github.com/IBM/fp-go/v2/io"
|
||||
O "github.com/IBM/fp-go/v2/option"
|
||||
S "github.com/IBM/fp-go/v2/string"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -120,10 +119,3 @@ func TestStringer(t *testing.T) {
|
||||
var s fmt.Stringer = &e
|
||||
assert.Equal(t, exp, s.String())
|
||||
}
|
||||
|
||||
func TestFromIO(t *testing.T) {
|
||||
f := IO.Of("abc")
|
||||
e := FromIO[error](f)
|
||||
|
||||
assert.Equal(t, Right[error]("abc"), e)
|
||||
}
|
||||
|
||||
149
v2/either/examples_format_test.go
Normal file
149
v2/either/examples_format_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package either_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
E "github.com/IBM/fp-go/v2/either"
|
||||
)
|
||||
|
||||
// ExampleEither_String demonstrates the fmt.Stringer interface implementation.
|
||||
func ExampleEither_String() {
|
||||
right := E.Right[error](42)
|
||||
left := E.Left[int](errors.New("something went wrong"))
|
||||
|
||||
fmt.Println(right.String())
|
||||
fmt.Println(left.String())
|
||||
|
||||
// Output:
|
||||
// Right[int](42)
|
||||
// Left[*errors.errorString](something went wrong)
|
||||
}
|
||||
|
||||
// ExampleEither_GoString demonstrates the fmt.GoStringer interface implementation.
|
||||
func ExampleEither_GoString() {
|
||||
right := E.Right[error](42)
|
||||
left := E.Left[int](errors.New("error"))
|
||||
|
||||
fmt.Printf("%#v\n", right)
|
||||
fmt.Printf("%#v\n", left)
|
||||
|
||||
// Output:
|
||||
// either.Right[error](42)
|
||||
// either.Left[int](&errors.errorString{s:"error"})
|
||||
}
|
||||
|
||||
// ExampleEither_Format demonstrates the fmt.Formatter interface implementation.
|
||||
func ExampleEither_Format() {
|
||||
result := E.Right[error](42)
|
||||
|
||||
// Different format verbs
|
||||
fmt.Printf("%%s: %s\n", result)
|
||||
fmt.Printf("%%v: %v\n", result)
|
||||
fmt.Printf("%%+v: %+v\n", result)
|
||||
fmt.Printf("%%#v: %#v\n", result)
|
||||
|
||||
// Output:
|
||||
// %s: Right[int](42)
|
||||
// %v: Right[int](42)
|
||||
// %+v: Right[int](42)
|
||||
// %#v: either.Right[error](42)
|
||||
}
|
||||
|
||||
// ExampleEither_LogValue demonstrates the slog.LogValuer interface implementation.
|
||||
func ExampleEither_LogValue() {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
// Remove time for consistent output
|
||||
if a.Key == slog.TimeKey {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
}))
|
||||
|
||||
// Right value
|
||||
rightResult := E.Right[error](42)
|
||||
logger.Info("computation succeeded", "result", rightResult)
|
||||
|
||||
// Left value
|
||||
leftResult := E.Left[int](errors.New("computation failed"))
|
||||
logger.Error("computation failed", "result", leftResult)
|
||||
|
||||
// Output:
|
||||
// level=INFO msg="computation succeeded" result.right=42
|
||||
// level=ERROR msg="computation failed" result.left="computation failed"
|
||||
}
|
||||
|
||||
// ExampleEither_formatting_comparison demonstrates different formatting options.
|
||||
func ExampleEither_formatting_comparison() {
|
||||
type User struct {
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
|
||||
user := User{ID: 123, Name: "Alice"}
|
||||
result := E.Right[error](user)
|
||||
|
||||
fmt.Printf("String(): %s\n", result.String())
|
||||
fmt.Printf("GoString(): %s\n", result.GoString())
|
||||
fmt.Printf("%%v: %v\n", result)
|
||||
fmt.Printf("%%#v: %#v\n", result)
|
||||
|
||||
// Output:
|
||||
// String(): Right[either_test.User]({123 Alice})
|
||||
// GoString(): either.Right[error](either_test.User{ID:123, Name:"Alice"})
|
||||
// %v: Right[either_test.User]({123 Alice})
|
||||
// %#v: either.Right[error](either_test.User{ID:123, Name:"Alice"})
|
||||
}
|
||||
|
||||
// ExampleEither_LogValue_structured demonstrates structured logging with Either.
|
||||
func ExampleEither_LogValue_structured() {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == slog.TimeKey {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
}))
|
||||
|
||||
// Simulate a computation pipeline
|
||||
compute := func(x int) E.Either[error, int] {
|
||||
if x < 0 {
|
||||
return E.Left[int](errors.New("negative input"))
|
||||
}
|
||||
return E.Right[error](x * 2)
|
||||
}
|
||||
|
||||
// Log successful computation
|
||||
result1 := compute(21)
|
||||
logger.Info("computation", "input", 21, "output", result1)
|
||||
|
||||
// Log failed computation
|
||||
result2 := compute(-5)
|
||||
logger.Error("computation", "input", -5, "output", result2)
|
||||
|
||||
// Output:
|
||||
// level=INFO msg=computation input=21 output.right=42
|
||||
// level=ERROR msg=computation input=-5 output.left="negative input"
|
||||
}
|
||||
38
v2/either/filter.go
Normal file
38
v2/either/filter.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package either
|
||||
|
||||
// FilterOrElse filters an Either value based on a predicate.
|
||||
// If the Either is Right and the predicate returns true, returns the original Right.
|
||||
// If the Either is Right and the predicate returns false, returns Left with the error from onFalse.
|
||||
// If the Either is Left, returns the original Left without applying the predicate.
|
||||
//
|
||||
// This is useful for adding validation to Right values, converting them to Left if they don't meet certain criteria.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// isPositive := N.MoreThan(0)
|
||||
// onNegative := func(x int) error { return fmt.Errorf("%d is not positive", x) }
|
||||
// filter := either.FilterOrElse(isPositive, onNegative)
|
||||
//
|
||||
// result1 := filter(either.Right[error](5)) // Right(5)
|
||||
// result2 := filter(either.Right[error](-3)) // Left(error: "-3 is not positive")
|
||||
// result3 := filter(either.Left[int](someError)) // Left(someError)
|
||||
//
|
||||
//go:inline
|
||||
func FilterOrElse[E, A any](pred Predicate[A], onFalse func(A) E) Operator[E, A, A] {
|
||||
return Chain(FromPredicate(pred, onFalse))
|
||||
}
|
||||
143
v2/either/filter_test.go
Normal file
143
v2/either/filter_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package either
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
N "github.com/IBM/fp-go/v2/number"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFilterOrElse(t *testing.T) {
|
||||
// Test with positive predicate
|
||||
isPositive := N.MoreThan(0)
|
||||
onNegative := func(x int) error { return fmt.Errorf("%d is not positive", x) }
|
||||
filter := FilterOrElse(isPositive, onNegative)
|
||||
|
||||
// Test Right value that passes predicate
|
||||
result := filter(Right[error](5))
|
||||
assert.Equal(t, Right[error](5), result)
|
||||
|
||||
// Test Right value that fails predicate
|
||||
result = filter(Right[error](-3))
|
||||
assert.True(t, IsLeft(result))
|
||||
left, _ := UnwrapError(result)
|
||||
assert.Equal(t, 0, left) // default value for int
|
||||
|
||||
// Test Right value at boundary (zero)
|
||||
result = filter(Right[error](0))
|
||||
assert.True(t, IsLeft(result))
|
||||
|
||||
// Test Left value (should pass through unchanged)
|
||||
originalError := errors.New("original error")
|
||||
result = filter(Left[int](originalError))
|
||||
assert.Equal(t, Left[int](originalError), result)
|
||||
}
|
||||
|
||||
func TestFilterOrElse_StringValidation(t *testing.T) {
|
||||
// Test with string length validation
|
||||
isNotEmpty := func(s string) bool { return len(s) > 0 }
|
||||
onEmpty := func(s string) error { return errors.New("string is empty") }
|
||||
filter := FilterOrElse(isNotEmpty, onEmpty)
|
||||
|
||||
// Test non-empty string
|
||||
result := filter(Right[error]("hello"))
|
||||
assert.Equal(t, Right[error]("hello"), result)
|
||||
|
||||
// Test empty string
|
||||
result = filter(Right[error](""))
|
||||
assert.True(t, IsLeft(result))
|
||||
|
||||
// Test Left value
|
||||
originalError := errors.New("validation error")
|
||||
result = filter(Left[string](originalError))
|
||||
assert.Equal(t, Left[string](originalError), result)
|
||||
}
|
||||
|
||||
func TestFilterOrElse_ComplexPredicate(t *testing.T) {
|
||||
// Test with range validation
|
||||
inRange := func(x int) bool { return x >= 10 && x <= 100 }
|
||||
outOfRange := func(x int) error { return fmt.Errorf("%d is out of range [10, 100]", x) }
|
||||
filter := FilterOrElse(inRange, outOfRange)
|
||||
|
||||
// Test value in range
|
||||
result := filter(Right[error](50))
|
||||
assert.Equal(t, Right[error](50), result)
|
||||
|
||||
// Test value below range
|
||||
result = filter(Right[error](5))
|
||||
assert.True(t, IsLeft(result))
|
||||
|
||||
// Test value above range
|
||||
result = filter(Right[error](150))
|
||||
assert.True(t, IsLeft(result))
|
||||
|
||||
// Test boundary values
|
||||
result = filter(Right[error](10))
|
||||
assert.Equal(t, Right[error](10), result)
|
||||
|
||||
result = filter(Right[error](100))
|
||||
assert.Equal(t, Right[error](100), result)
|
||||
}
|
||||
|
||||
func TestFilterOrElse_ChainedFilters(t *testing.T) {
|
||||
// Test chaining multiple filters
|
||||
isPositive := N.MoreThan(0)
|
||||
onNegative := func(x int) error { return fmt.Errorf("%d is not positive", x) }
|
||||
|
||||
isEven := func(x int) bool { return x%2 == 0 }
|
||||
onOdd := func(x int) error { return fmt.Errorf("%d is not even", x) }
|
||||
|
||||
filterPositive := FilterOrElse(isPositive, onNegative)
|
||||
filterEven := FilterOrElse(isEven, onOdd)
|
||||
|
||||
// Test value that passes both filters
|
||||
result := filterEven(filterPositive(Right[error](4)))
|
||||
assert.Equal(t, Right[error](4), result)
|
||||
|
||||
// Test value that fails first filter
|
||||
result = filterEven(filterPositive(Right[error](-2)))
|
||||
assert.True(t, IsLeft(result))
|
||||
|
||||
// Test value that passes first but fails second filter
|
||||
result = filterEven(filterPositive(Right[error](3)))
|
||||
assert.True(t, IsLeft(result))
|
||||
}
|
||||
|
||||
func TestFilterOrElse_WithStructs(t *testing.T) {
|
||||
type User struct {
|
||||
Name string
|
||||
Age int
|
||||
}
|
||||
|
||||
// Test with struct validation
|
||||
isAdult := func(u User) bool { return u.Age >= 18 }
|
||||
onMinor := func(u User) error { return fmt.Errorf("%s is not an adult (age: %d)", u.Name, u.Age) }
|
||||
filter := FilterOrElse(isAdult, onMinor)
|
||||
|
||||
// Test adult user
|
||||
adult := User{Name: "Alice", Age: 25}
|
||||
result := filter(Right[error](adult))
|
||||
assert.Equal(t, Right[error](adult), result)
|
||||
|
||||
// Test minor user
|
||||
minor := User{Name: "Bob", Age: 16}
|
||||
result = filter(Right[error](minor))
|
||||
assert.True(t, IsLeft(result))
|
||||
}
|
||||
103
v2/either/format.go
Normal file
103
v2/either/format.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package either
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/IBM/fp-go/v2/internal/formatting"
|
||||
)
|
||||
|
||||
const (
|
||||
leftGoTemplate = "either.Left[%s](%#v)"
|
||||
rightGoTemplate = "either.Right[%s](%#v)"
|
||||
|
||||
leftFmtTemplate = "Left[%T](%v)"
|
||||
rightFmtTemplate = "Right[%T](%v)"
|
||||
)
|
||||
|
||||
func goString(template string, other, v any) string {
|
||||
return fmt.Sprintf(template, formatting.TypeInfo(other), v)
|
||||
}
|
||||
|
||||
// String prints some debug info for the object
|
||||
//
|
||||
//go:noinline
|
||||
func (s Either[E, A]) String() string {
|
||||
if !s.isLeft {
|
||||
return fmt.Sprintf(rightFmtTemplate, s.r, s.r)
|
||||
}
|
||||
return fmt.Sprintf(leftFmtTemplate, s.l, s.l)
|
||||
}
|
||||
|
||||
// Format implements fmt.Formatter for Either.
|
||||
// Supports all standard format verbs:
|
||||
// - %s, %v, %+v: uses String() representation
|
||||
// - %#v: uses GoString() representation
|
||||
// - %q: quoted String() representation
|
||||
// - other verbs: uses String() representation
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// e := either.Right[error](42)
|
||||
// fmt.Printf("%s", e) // "Right[int](42)"
|
||||
// fmt.Printf("%v", e) // "Right[int](42)"
|
||||
// fmt.Printf("%#v", e) // "either.Right[error](42)"
|
||||
//
|
||||
//go:noinline
|
||||
func (s Either[E, A]) Format(f fmt.State, c rune) {
|
||||
formatting.FmtString(s, f, c)
|
||||
}
|
||||
|
||||
// GoString implements fmt.GoStringer for Either.
|
||||
// Returns a Go-syntax representation of the Either value.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// either.Right[error](42).GoString() // "either.Right[error](42)"
|
||||
// either.Left[int](errors.New("fail")).GoString() // "either.Left[int](error)"
|
||||
//
|
||||
//go:noinline
|
||||
func (s Either[E, A]) GoString() string {
|
||||
if !s.isLeft {
|
||||
return goString(rightGoTemplate, new(E), s.r)
|
||||
}
|
||||
return goString(leftGoTemplate, new(A), s.l)
|
||||
}
|
||||
|
||||
// LogValue implements slog.LogValuer for Either.
|
||||
// Returns a slog.Value that represents the Either for structured logging.
|
||||
// Returns a group value with "right" key for Right values and "left" key for Left values.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// logger := slog.Default()
|
||||
// result := either.Right[error](42)
|
||||
// logger.Info("result", "value", result)
|
||||
// // Logs: {"msg":"result","value":{"right":42}}
|
||||
//
|
||||
// err := either.Left[int](errors.New("failed"))
|
||||
// logger.Error("error", "value", err)
|
||||
// // Logs: {"msg":"error","value":{"left":"failed"}}
|
||||
//
|
||||
//go:noinline
|
||||
func (s Either[E, A]) LogValue() slog.Value {
|
||||
if !s.isLeft {
|
||||
return slog.GroupValue(slog.Any("right", s.r))
|
||||
}
|
||||
return slog.GroupValue(slog.Any("left", s.l))
|
||||
}
|
||||
311
v2/either/format_test.go
Normal file
311
v2/either/format_test.go
Normal file
@@ -0,0 +1,311 @@
|
||||
// Copyright (c) 2023 - 2025 IBM Corp.
|
||||
// All rights reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package either
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestString(t *testing.T) {
|
||||
t.Run("Right value", func(t *testing.T) {
|
||||
e := Right[error](42)
|
||||
result := e.String()
|
||||
assert.Equal(t, "Right[int](42)", result)
|
||||
})
|
||||
|
||||
t.Run("Left value", func(t *testing.T) {
|
||||
e := Left[int](errors.New("test error"))
|
||||
result := e.String()
|
||||
assert.Contains(t, result, "Left[*errors.errorString]")
|
||||
assert.Contains(t, result, "test error")
|
||||
})
|
||||
|
||||
t.Run("Right with string", func(t *testing.T) {
|
||||
e := Right[error]("hello")
|
||||
result := e.String()
|
||||
assert.Equal(t, "Right[string](hello)", result)
|
||||
})
|
||||
|
||||
t.Run("Left with string", func(t *testing.T) {
|
||||
e := Left[int]("error message")
|
||||
result := e.String()
|
||||
assert.Equal(t, "Left[string](error message)", result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGoString(t *testing.T) {
|
||||
t.Run("Right value", func(t *testing.T) {
|
||||
e := Right[error](42)
|
||||
result := e.GoString()
|
||||
assert.Contains(t, result, "either.Right")
|
||||
assert.Contains(t, result, "42")
|
||||
})
|
||||
|
||||
t.Run("Left value", func(t *testing.T) {
|
||||
e := Left[int](errors.New("test error"))
|
||||
result := e.GoString()
|
||||
assert.Contains(t, result, "either.Left")
|
||||
assert.Contains(t, result, "test error")
|
||||
})
|
||||
|
||||
t.Run("Right with struct", func(t *testing.T) {
|
||||
type TestStruct struct {
|
||||
Name string
|
||||
Age int
|
||||
}
|
||||
e := Right[error](TestStruct{Name: "Alice", Age: 30})
|
||||
result := e.GoString()
|
||||
assert.Contains(t, result, "either.Right")
|
||||
assert.Contains(t, result, "Alice")
|
||||
assert.Contains(t, result, "30")
|
||||
})
|
||||
|
||||
t.Run("Left with custom error", func(t *testing.T) {
|
||||
e := Left[string]("custom error")
|
||||
result := e.GoString()
|
||||
assert.Contains(t, result, "either.Left")
|
||||
assert.Contains(t, result, "custom error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatInterface(t *testing.T) {
|
||||
t.Run("Right value with %s", func(t *testing.T) {
|
||||
e := Right[error](42)
|
||||
result := fmt.Sprintf("%s", e)
|
||||
assert.Equal(t, "Right[int](42)", result)
|
||||
})
|
||||
|
||||
t.Run("Left value with %s", func(t *testing.T) {
|
||||
e := Left[int](errors.New("test error"))
|
||||
result := fmt.Sprintf("%s", e)
|
||||
assert.Contains(t, result, "Left")
|
||||
assert.Contains(t, result, "test error")
|
||||
})
|
||||
|
||||
t.Run("Right value with %v", func(t *testing.T) {
|
||||
e := Right[error](42)
|
||||
result := fmt.Sprintf("%v", e)
|
||||
assert.Equal(t, "Right[int](42)", result)
|
||||
})
|
||||
|
||||
t.Run("Left value with %v", func(t *testing.T) {
|
||||
e := Left[int]("error")
|
||||
result := fmt.Sprintf("%v", e)
|
||||
assert.Equal(t, "Left[string](error)", result)
|
||||
})
|
||||
|
||||
t.Run("Right value with %+v", func(t *testing.T) {
|
||||
e := Right[error](42)
|
||||
result := fmt.Sprintf("%+v", e)
|
||||
assert.Contains(t, result, "Right")
|
||||
assert.Contains(t, result, "42")
|
||||
})
|
||||
|
||||
t.Run("Right value with %#v (GoString)", func(t *testing.T) {
|
||||
e := Right[error](42)
|
||||
result := fmt.Sprintf("%#v", e)
|
||||
assert.Contains(t, result, "either.Right")
|
||||
assert.Contains(t, result, "42")
|
||||
})
|
||||
|
||||
t.Run("Left value with %#v (GoString)", func(t *testing.T) {
|
||||
e := Left[int]("error")
|
||||
result := fmt.Sprintf("%#v", e)
|
||||
assert.Contains(t, result, "either.Left")
|
||||
assert.Contains(t, result, "error")
|
||||
})
|
||||
|
||||
t.Run("Right value with %q", func(t *testing.T) {
|
||||
e := Right[error]("hello")
|
||||
result := fmt.Sprintf("%q", e)
|
||||
// Should use String() representation
|
||||
assert.Contains(t, result, "Right")
|
||||
})
|
||||
|
||||
t.Run("Right value with %T", func(t *testing.T) {
|
||||
e := Right[error](42)
|
||||
result := fmt.Sprintf("%T", e)
|
||||
assert.Contains(t, result, "either.Either")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogValue(t *testing.T) {
|
||||
t.Run("Right value", func(t *testing.T) {
|
||||
e := Right[error](42)
|
||||
logValue := e.LogValue()
|
||||
|
||||
// Should be a group value
|
||||
assert.Equal(t, slog.KindGroup, logValue.Kind())
|
||||
|
||||
// Extract the group attributes
|
||||
attrs := logValue.Group()
|
||||
assert.Len(t, attrs, 1)
|
||||
assert.Equal(t, "right", attrs[0].Key)
|
||||
assert.Equal(t, int64(42), attrs[0].Value.Any())
|
||||
})
|
||||
|
||||
t.Run("Left value", func(t *testing.T) {
|
||||
e := Left[int](errors.New("test error"))
|
||||
logValue := e.LogValue()
|
||||
|
||||
// Should be a group value
|
||||
assert.Equal(t, slog.KindGroup, logValue.Kind())
|
||||
|
||||
// Extract the group attributes
|
||||
attrs := logValue.Group()
|
||||
assert.Len(t, attrs, 1)
|
||||
assert.Equal(t, "left", attrs[0].Key)
|
||||
assert.NotNil(t, attrs[0].Value.Any())
|
||||
})
|
||||
|
||||
t.Run("Right with string", func(t *testing.T) {
|
||||
e := Right[error]("success")
|
||||
logValue := e.LogValue()
|
||||
|
||||
// Should be a group value
|
||||
assert.Equal(t, slog.KindGroup, logValue.Kind())
|
||||
|
||||
// Extract the group attributes
|
||||
attrs := logValue.Group()
|
||||
assert.Len(t, attrs, 1)
|
||||
assert.Equal(t, "right", attrs[0].Key)
|
||||
assert.Equal(t, "success", attrs[0].Value.Any())
|
||||
})
|
||||
|
||||
t.Run("Left with string", func(t *testing.T) {
|
||||
e := Left[int]("error message")
|
||||
logValue := e.LogValue()
|
||||
|
||||
// Should be a group value
|
||||
assert.Equal(t, slog.KindGroup, logValue.Kind())
|
||||
|
||||
// Extract the group attributes
|
||||
attrs := logValue.Group()
|
||||
assert.Len(t, attrs, 1)
|
||||
assert.Equal(t, "left", attrs[0].Key)
|
||||
assert.Equal(t, "error message", attrs[0].Value.Any())
|
||||
})
|
||||
|
||||
t.Run("Integration with slog - Right", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
|
||||
e := Right[error](42)
|
||||
logger.Info("test message", "result", e)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "test message")
|
||||
assert.Contains(t, output, "result")
|
||||
assert.Contains(t, output, "right")
|
||||
assert.Contains(t, output, "42")
|
||||
})
|
||||
|
||||
t.Run("Integration with slog - Left", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
|
||||
e := Left[int]("error occurred")
|
||||
logger.Info("test message", "result", e)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "test message")
|
||||
assert.Contains(t, output, "result")
|
||||
assert.Contains(t, output, "left")
|
||||
assert.Contains(t, output, "error occurred")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatComprehensive(t *testing.T) {
|
||||
t.Run("All format verbs for Right", func(t *testing.T) {
|
||||
e := Right[error](42)
|
||||
|
||||
tests := []struct {
|
||||
verb string
|
||||
contains []string
|
||||
}{
|
||||
{"%s", []string{"Right", "42"}},
|
||||
{"%v", []string{"Right", "42"}},
|
||||
{"%+v", []string{"Right", "42"}},
|
||||
{"%#v", []string{"either.Right", "42"}},
|
||||
{"%T", []string{"either.Either"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.verb, func(t *testing.T) {
|
||||
result := fmt.Sprintf(tt.verb, e)
|
||||
for _, substr := range tt.contains {
|
||||
assert.Contains(t, result, substr, "Format %s should contain %s", tt.verb, substr)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("All format verbs for Left", func(t *testing.T) {
|
||||
e := Left[int]("error")
|
||||
|
||||
tests := []struct {
|
||||
verb string
|
||||
contains []string
|
||||
}{
|
||||
{"%s", []string{"Left", "error"}},
|
||||
{"%v", []string{"Left", "error"}},
|
||||
{"%+v", []string{"Left", "error"}},
|
||||
{"%#v", []string{"either.Left", "error"}},
|
||||
{"%T", []string{"either.Either"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.verb, func(t *testing.T) {
|
||||
result := fmt.Sprintf(tt.verb, e)
|
||||
for _, substr := range tt.contains {
|
||||
assert.Contains(t, result, substr, "Format %s should contain %s", tt.verb, substr)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestInterfaceImplementations(t *testing.T) {
|
||||
t.Run("fmt.Stringer interface", func(t *testing.T) {
|
||||
var _ fmt.Stringer = Right[error](42)
|
||||
var _ fmt.Stringer = Left[int](errors.New("error"))
|
||||
})
|
||||
|
||||
t.Run("fmt.GoStringer interface", func(t *testing.T) {
|
||||
var _ fmt.GoStringer = Right[error](42)
|
||||
var _ fmt.GoStringer = Left[int](errors.New("error"))
|
||||
})
|
||||
|
||||
t.Run("fmt.Formatter interface", func(t *testing.T) {
|
||||
var _ fmt.Formatter = Right[error](42)
|
||||
var _ fmt.Formatter = Left[int](errors.New("error"))
|
||||
})
|
||||
|
||||
t.Run("slog.LogValuer interface", func(t *testing.T) {
|
||||
var _ slog.LogValuer = Right[error](42)
|
||||
var _ slog.LogValuer = Left[int](errors.New("error"))
|
||||
})
|
||||
}
|
||||
@@ -17,11 +17,19 @@ package either
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
L "github.com/IBM/fp-go/v2/logging"
|
||||
)
|
||||
|
||||
var (
|
||||
// slogError creates a slog.Attr with key "error" for logging error values
|
||||
slogError = F.Bind1st(slog.Any, "error")
|
||||
// slogValue creates a slog.Attr with key "value" for logging success values
|
||||
slogValue = F.Bind1st(slog.Any, "value")
|
||||
)
|
||||
|
||||
func _log[E, A any](left func(string, ...any), right func(string, ...any), prefix string) Operator[E, A, A] {
|
||||
return Fold(
|
||||
func(e E) Either[E, A] {
|
||||
@@ -62,3 +70,91 @@ func Logger[E, A any](loggers ...*log.Logger) func(string) Operator[E, A, A] {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ToSLogAttr converts an Either value to a structured logging attribute (slog.Attr).
|
||||
//
|
||||
// This function creates a converter that transforms Either values into slog.Attr for use
|
||||
// with Go's structured logging (log/slog). It maps:
|
||||
// - Left values to an "error" attribute
|
||||
// - Right values to a "value" attribute
|
||||
//
|
||||
// This is particularly useful when integrating Either-based error handling with structured
|
||||
// logging systems, allowing you to log both successful values and errors in a consistent,
|
||||
// structured format.
|
||||
//
|
||||
// Type Parameters:
|
||||
// - E: The Left (error) type of the Either
|
||||
// - A: The Right (success) type of the Either
|
||||
//
|
||||
// Returns:
|
||||
// - A function that converts Either[E, A] to slog.Attr
|
||||
//
|
||||
// Example with Left (error):
|
||||
//
|
||||
// converter := either.ToSLogAttr[error, int]()
|
||||
// leftValue := either.Left[int](errors.New("connection failed"))
|
||||
// attr := converter(leftValue)
|
||||
// // attr is: slog.Any("error", errors.New("connection failed"))
|
||||
//
|
||||
// logger.LogAttrs(ctx, slog.LevelError, "Operation failed", attr)
|
||||
// // Logs: {"level":"error","msg":"Operation failed","error":"connection failed"}
|
||||
//
|
||||
// Example with Right (success):
|
||||
//
|
||||
// converter := either.ToSLogAttr[error, User]()
|
||||
// rightValue := either.Right[error](User{ID: 123, Name: "Alice"})
|
||||
// attr := converter(rightValue)
|
||||
// // attr is: slog.Any("value", User{ID: 123, Name: "Alice"})
|
||||
//
|
||||
// logger.LogAttrs(ctx, slog.LevelInfo, "User fetched", attr)
|
||||
// // Logs: {"level":"info","msg":"User fetched","value":{"ID":123,"Name":"Alice"}}
|
||||
//
|
||||
// Example in a pipeline with structured logging:
|
||||
//
|
||||
// toAttr := either.ToSLogAttr[error, Data]()
|
||||
//
|
||||
// result := F.Pipe2(
|
||||
// fetchData(id),
|
||||
// either.Map(processData),
|
||||
// either.Map(validateData),
|
||||
// )
|
||||
//
|
||||
// attr := toAttr(result)
|
||||
// logger.LogAttrs(ctx, slog.LevelInfo, "Data processing complete", attr)
|
||||
// // Logs success: {"level":"info","msg":"Data processing complete","value":{...}}
|
||||
// // Or error: {"level":"info","msg":"Data processing complete","error":"validation failed"}
|
||||
//
|
||||
// Example with custom log levels based on Either:
|
||||
//
|
||||
// toAttr := either.ToSLogAttr[error, Response]()
|
||||
// result := callAPI(endpoint)
|
||||
//
|
||||
// level := either.Fold(
|
||||
// func(error) slog.Level { return slog.LevelError },
|
||||
// func(Response) slog.Level { return slog.LevelInfo },
|
||||
// )(result)
|
||||
//
|
||||
// logger.LogAttrs(ctx, level, "API call completed", toAttr(result))
|
||||
//
|
||||
// Use Cases:
|
||||
// - Structured logging: Convert Either results to structured log attributes
|
||||
// - Error tracking: Log errors with consistent "error" key in structured logs
|
||||
// - Success monitoring: Log successful values with consistent "value" key
|
||||
// - Observability: Integrate Either-based error handling with logging systems
|
||||
// - Debugging: Inspect Either values in logs with proper structure
|
||||
// - Metrics: Extract Either values for metrics collection in logging pipelines
|
||||
//
|
||||
// Note: The returned slog.Attr uses "error" for Left values and "value" for Right values.
|
||||
// These keys are consistent with common structured logging conventions.
|
||||
func ToSLogAttr[E, A any]() func(Either[E, A]) slog.Attr {
|
||||
return Fold(
|
||||
F.Flow2(
|
||||
F.ToAny[E],
|
||||
slogError,
|
||||
),
|
||||
F.Flow2(
|
||||
F.ToAny[A],
|
||||
slogValue,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,9 +16,12 @@
|
||||
package either
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
F "github.com/IBM/fp-go/v2/function"
|
||||
N "github.com/IBM/fp-go/v2/number"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -35,3 +38,139 @@ func TestLogger(t *testing.T) {
|
||||
|
||||
assert.Equal(t, r, res)
|
||||
}
|
||||
|
||||
func TestToSLogAttr_Left(t *testing.T) {
|
||||
// Test with Left (error) value
|
||||
converter := ToSLogAttr[error, int]()
|
||||
testErr := errors.New("test error")
|
||||
leftValue := Left[int](testErr)
|
||||
|
||||
attr := converter(leftValue)
|
||||
|
||||
// Verify the attribute has the correct key
|
||||
assert.Equal(t, "error", attr.Key)
|
||||
// Verify the attribute value is the error
|
||||
assert.Equal(t, testErr, attr.Value.Any())
|
||||
}
|
||||
|
||||
func TestToSLogAttr_Right(t *testing.T) {
|
||||
// Test with Right (success) value
|
||||
converter := ToSLogAttr[error, string]()
|
||||
rightValue := Right[error]("success value")
|
||||
|
||||
attr := converter(rightValue)
|
||||
|
||||
// Verify the attribute has the correct key
|
||||
assert.Equal(t, "value", attr.Key)
|
||||
// Verify the attribute value is the success value
|
||||
assert.Equal(t, "success value", attr.Value.Any())
|
||||
}
|
||||
|
||||
func TestToSLogAttr_LeftWithCustomType(t *testing.T) {
|
||||
// Test with custom error type
|
||||
type CustomError struct {
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
converter := ToSLogAttr[CustomError, string]()
|
||||
customErr := CustomError{Code: 404, Message: "not found"}
|
||||
leftValue := Left[string](customErr)
|
||||
|
||||
attr := converter(leftValue)
|
||||
|
||||
assert.Equal(t, "error", attr.Key)
|
||||
assert.Equal(t, customErr, attr.Value.Any())
|
||||
}
|
||||
|
||||
func TestToSLogAttr_RightWithCustomType(t *testing.T) {
|
||||
// Test with custom success type
|
||||
type User struct {
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
|
||||
converter := ToSLogAttr[error, User]()
|
||||
user := User{ID: 123, Name: "Alice"}
|
||||
rightValue := Right[error](user)
|
||||
|
||||
attr := converter(rightValue)
|
||||
|
||||
assert.Equal(t, "value", attr.Key)
|
||||
assert.Equal(t, user, attr.Value.Any())
|
||||
}
|
||||
|
||||
func TestToSLogAttr_InPipeline(t *testing.T) {
|
||||
// Test ToSLogAttr in a functional pipeline
|
||||
converter := ToSLogAttr[error, int]()
|
||||
|
||||
// Test with successful pipeline
|
||||
successResult := F.Pipe2(
|
||||
Right[error](10),
|
||||
Map[error](N.Mul(2)),
|
||||
converter,
|
||||
)
|
||||
|
||||
assert.Equal(t, "value", successResult.Key)
|
||||
// slog.Any converts int to int64
|
||||
assert.Equal(t, int64(20), successResult.Value.Any())
|
||||
|
||||
// Test with failed pipeline
|
||||
testErr := errors.New("computation failed")
|
||||
failureResult := F.Pipe2(
|
||||
Left[int](testErr),
|
||||
Map[error](N.Mul(2)),
|
||||
converter,
|
||||
)
|
||||
|
||||
assert.Equal(t, "error", failureResult.Key)
|
||||
assert.Equal(t, testErr, failureResult.Value.Any())
|
||||
}
|
||||
|
||||
func TestToSLogAttr_WithNilError(t *testing.T) {
|
||||
// Test with nil error (edge case)
|
||||
converter := ToSLogAttr[error, string]()
|
||||
var nilErr error = nil
|
||||
leftValue := Left[string](nilErr)
|
||||
|
||||
attr := converter(leftValue)
|
||||
|
||||
assert.Equal(t, "error", attr.Key)
|
||||
assert.Nil(t, attr.Value.Any())
|
||||
}
|
||||
|
||||
func TestToSLogAttr_WithZeroValue(t *testing.T) {
|
||||
// Test with zero value of success type
|
||||
converter := ToSLogAttr[error, int]()
|
||||
rightValue := Right[error](0)
|
||||
|
||||
attr := converter(rightValue)
|
||||
|
||||
assert.Equal(t, "value", attr.Key)
|
||||
// slog.Any converts int to int64
|
||||
assert.Equal(t, int64(0), attr.Value.Any())
|
||||
}
|
||||
|
||||
func TestToSLogAttr_WithEmptyString(t *testing.T) {
|
||||
// Test with empty string as success value
|
||||
converter := ToSLogAttr[error, string]()
|
||||
rightValue := Right[error]("")
|
||||
|
||||
attr := converter(rightValue)
|
||||
|
||||
assert.Equal(t, "value", attr.Key)
|
||||
assert.Equal(t, "", attr.Value.Any())
|
||||
}
|
||||
|
||||
func TestToSLogAttr_AttributeKind(t *testing.T) {
|
||||
// Verify that the returned attribute has the correct Kind
|
||||
converter := ToSLogAttr[error, string]()
|
||||
|
||||
leftAttr := converter(Left[string](errors.New("error")))
|
||||
// Errors are stored as KindAny (which has value 0)
|
||||
assert.Equal(t, slog.KindAny, leftAttr.Value.Kind())
|
||||
|
||||
rightAttr := converter(Right[error]("value"))
|
||||
// Strings have KindString
|
||||
assert.Equal(t, slog.KindString, rightAttr.Value.Kind())
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user