diff --git a/README.md b/README.md index eaba4ea..cf9d8c3 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ ## Features - Memoizes functions with TTL, supporting 0 to 7 comparable parameters. [List of Memoize Functions](https://github.com/agkloop/go_memoize/blob/main/memoize.go) +- Error-aware variants (suffix `E`) that support compute functions returning `(V, error)` and do NOT cache when an error is returned. - High performance, zero allocation, and zero dependencies. - Utilizes the FNV-1a hash algorithm for caching. - Thread-safe and concurrent-safe. @@ -44,6 +45,36 @@ memoizedCtxFn := MemoizeCtx(computeCtxFn, 10*time.Second) result := memoizedCtxFn(context.Background()) ``` +### Error-aware memoization (do not memoize errors) + +If your compute function can fail and returns `(V, error)`, use the `E` variants. These versions will NOT store a cached value when the compute function returns a non-nil error. This is useful for transient failures where you want the next call to retry the computation rather than returning a cached error result. + +Available `E` variants: +- `MemoizeE` (no-arg) +- `Memoize1E` .. `Memoize7E` (1..7 args) +- `MemoizeCtxE` and `MemoizeCtx1E` .. `MemoizeCtx7E` (context-aware) + +Behavior: +- If a cached value exists for the key, the function returns it and a nil error. +- If no cached value exists, the compute function is executed. + - If compute returns `(v, nil)`, `v` is cached and returned. + - If compute returns `(zeroValue, err)` (err != nil), the error is returned and nothing is cached. + +Example: + +```go +computeFn := func(id int) (string, error) { + // may return an error sometimes +} + +memo := Memoize1E(func(id int) (string, error) { return computeFn(id) }, 30*time.Second) + +val, err := memo(123) +if err != nil { + // transient error; next call will retry since nothing was cached +} +``` + ### Memoization with Parameters The package provides functions to memoize functions with up to 7 parameters. Here are some examples: @@ -124,6 +155,20 @@ result := memoizedCtxFn(context.Background(), 5, "example", 3.14) The `Cache` struct is used internally to manage the cached entries. It supports setting, getting, and deleting entries, as well as computing new values if they are not already cached or have expired. +## Testing + +Unit tests cover the memoization behavior, including the new error-aware variants. To run tests: + +```sh +# run all tests +go test ./... + +# run a specific test +go test ./... -run TestMemoizeE_DoesNotCacheError -v +``` + +New tests were added in `memoize_error_test.go` to verify that error results are not cached and that successful results are cached. + ## Example Here is a complete example of using the `memoize` package: @@ -244,6 +289,46 @@ result := memoizedFn(1, 2, 3, 4, 5, 6, 7) + + MemoizeE + Memoizes a function with no params, error-aware + +

+memoizedFn := MemoizeE(func() (int, error) { return 1, nil }, time.Minute)
+result, err := memoizedFn()
+      
+ + + + Memoize1E + Memoizes a function with 1 param, error-aware + +

+memoizedFn := Memoize1E(func(a int) (int, error) { return a * 2, nil }, time.Minute)
+result, err := memoizedFn(5)
+      
+ + + + Memoize2E + Memoizes a function with 2 params, error-aware + +

+memoizedFn := Memoize2E(func(a int, b string) (string, error) { return fmt.Sprintf("%d-%s", a, b), nil }, time.Minute)
+result, err := memoizedFn(5, "example")
+      
+ + + + Memoize3E + Memoizes a function with 3 params, error-aware + +

+memoizedFn := Memoize3E(func(a int, b string, c float64) (string, error) { return fmt.Sprintf("%d-%s-%f", a, b, c), nil }, time.Minute)
+result, err := memoizedFn(5, "example", 3.14)
+      
+ + MemoizeCtx Memoizes a function with context and no params diff --git a/memoize.go b/memoize.go index fe8ae85..9cf733d 100644 --- a/memoize.go +++ b/memoize.go @@ -91,3 +91,142 @@ func Memoize7[K1, K2, K3, K4, K5, K6, K7 comparable, V any](computeFn func(K1, K }) } } + +// --- New variants that return an error and avoid caching when computeFn returns a non-nil error --- + +// MemoizeE memoizes a function that returns (V, error). Errors are not cached. +func MemoizeE[V any](computeFn func() (V, error), ttl time.Duration) func() (V, error) { + cache := NewCacheSized[uint64, V](1, int64(ttl.Seconds())) + return func() (V, error) { + // try cached + if v, ok := cache.Get(0); ok { + return v, nil + } + // compute + v, err := computeFn() + if err != nil { + return zeroValue[V](), err + } + cache.Set(0, v) + return v, nil + } +} + +// Memoize1E memoizes a function with 1 arg that returns (V, error). Errors are not cached. +func Memoize1E[K comparable, V any](computeFn func(K) (V, error), ttl time.Duration) func(K) (V, error) { + cache := NewCache[uint64, V](int64(ttl.Seconds())) + return func(k K) (V, error) { + key := hash1(k) + if v, ok := cache.Get(key); ok { + return v, nil + } + v, err := computeFn(k) + if err != nil { + return zeroValue[V](), err + } + cache.Set(key, v) + return v, nil + } +} + +// Memoize2E memoizes a function with 2 args that returns (V, error). Errors are not cached. +func Memoize2E[K1, K2 comparable, V any](computeFn func(K1, K2) (V, error), ttl time.Duration) func(K1, K2) (V, error) { + cache := NewCache[uint64, V](int64(ttl.Seconds())) + return func(key1 K1, key2 K2) (V, error) { + key := hash2(key1, key2) + if v, ok := cache.Get(key); ok { + return v, nil + } + v, err := computeFn(key1, key2) + if err != nil { + return zeroValue[V](), err + } + cache.Set(key, v) + return v, nil + } +} + +// Memoize3E memoizes a function with 3 args that returns (V, error). Errors are not cached. +func Memoize3E[K1, K2, K3 comparable, V any](computeFn func(K1, K2, K3) (V, error), ttl time.Duration) func(K1, K2, K3) (V, error) { + cache := NewCache[uint64, V](int64(ttl.Seconds())) + return func(key1 K1, key2 K2, key3 K3) (V, error) { + key := hash3(key1, key2, key3) + if v, ok := cache.Get(key); ok { + return v, nil + } + v, err := computeFn(key1, key2, key3) + if err != nil { + return zeroValue[V](), err + } + cache.Set(key, v) + return v, nil + } +} + +// Memoize4E memoizes a function with 4 args that returns (V, error). Errors are not cached. +func Memoize4E[K1, K2, K3, K4 comparable, V any](computeFn func(K1, K2, K3, K4) (V, error), ttl time.Duration) func(K1, K2, K3, K4) (V, error) { + cache := NewCache[uint64, V](int64(ttl.Seconds())) + return func(key1 K1, key2 K2, key3 K3, key4 K4) (V, error) { + key := hash4(key1, key2, key3, key4) + if v, ok := cache.Get(key); ok { + return v, nil + } + v, err := computeFn(key1, key2, key3, key4) + if err != nil { + return zeroValue[V](), err + } + cache.Set(key, v) + return v, nil + } +} + +// Memoize5E memoizes a function with 5 args that returns (V, error). Errors are not cached. +func Memoize5E[K1, K2, K3, K4, K5 comparable, V any](computeFn func(K1, K2, K3, K4, K5) (V, error), ttl time.Duration) func(K1, K2, K3, K4, K5) (V, error) { + cache := NewCache[uint64, V](int64(ttl.Seconds())) + return func(key1 K1, key2 K2, key3 K3, key4 K4, key5 K5) (V, error) { + key := hash5(key1, key2, key3, key4, key5) + if v, ok := cache.Get(key); ok { + return v, nil + } + v, err := computeFn(key1, key2, key3, key4, key5) + if err != nil { + return zeroValue[V](), err + } + cache.Set(key, v) + return v, nil + } +} + +// Memoize6E memoizes a function with 6 args that returns (V, error). Errors are not cached. +func Memoize6E[K1, K2, K3, K4, K5, K6 comparable, V any](computeFn func(K1, K2, K3, K4, K5, K6) (V, error), ttl time.Duration) func(K1, K2, K3, K4, K5, K6) (V, error) { + cache := NewCache[uint64, V](int64(ttl.Seconds())) + return func(key1 K1, key2 K2, key3 K3, key4 K4, key5 K5, key6 K6) (V, error) { + key := hash6(key1, key2, key3, key4, key5, key6) + if v, ok := cache.Get(key); ok { + return v, nil + } + v, err := computeFn(key1, key2, key3, key4, key5, key6) + if err != nil { + return zeroValue[V](), err + } + cache.Set(key, v) + return v, nil + } +} + +// Memoize7E memoizes a function with 7 args that returns (V, error). Errors are not cached. +func Memoize7E[K1, K2, K3, K4, K5, K6, K7 comparable, V any](computeFn func(K1, K2, K3, K4, K5, K6, K7) (V, error), ttl time.Duration) func(K1, K2, K3, K4, K5, K6, K7) (V, error) { + cache := NewCache[uint64, V](int64(ttl.Seconds())) + return func(key1 K1, key2 K2, key3 K3, key4 K4, key5 K5, key6 K6, key7 K7) (V, error) { + key := hash7(key1, key2, key3, key4, key5, key6, key7) + if v, ok := cache.Get(key); ok { + return v, nil + } + v, err := computeFn(key1, key2, key3, key4, key5, key6, key7) + if err != nil { + return zeroValue[V](), err + } + cache.Set(key, v) + return v, nil + } +} diff --git a/memoize_ctx.go b/memoize_ctx.go index 991c1df..65ab1b7 100644 --- a/memoize_ctx.go +++ b/memoize_ctx.go @@ -84,3 +84,140 @@ func MemoizeCtx7[K1, K2, K3, K4, K5, K6, K7 comparable, V any](computeFn func(co }) } } + +// --- New context-aware variants returning (V, error) that avoid caching errors --- + +// MemoizeCtxE memoizes a context-aware function returning (V, error). Errors are not cached. +func MemoizeCtxE[V any](computeFn func(context.Context) (V, error), ttl time.Duration) func(context.Context) (V, error) { + cache := NewCacheSized[uint64, V](1, int64(ttl.Seconds())) + return func(ctx context.Context) (V, error) { + if v, ok := cache.Get(0); ok { + return v, nil + } + v, err := computeFn(ctx) + if err != nil { + return zeroValue[V](), err + } + cache.Set(0, v) + return v, nil + } +} + +// MemoizeCtx1E memoizes a context-aware function with 1 arg returning (V, error). Errors are not cached. +func MemoizeCtx1E[K comparable, V any](computeFn func(context.Context, K) (V, error), ttl time.Duration) func(context.Context, K) (V, error) { + cache := NewCache[uint64, V](int64(ttl.Seconds())) + return func(ctx context.Context, k K) (V, error) { + key := hash1(k) + if v, ok := cache.Get(key); ok { + return v, nil + } + v, err := computeFn(ctx, k) + if err != nil { + return zeroValue[V](), err + } + cache.Set(key, v) + return v, nil + } +} + +// MemoizeCtx2E memoizes a context-aware function with 2 args returning (V, error). Errors are not cached. +func MemoizeCtx2E[K1, K2 comparable, V any](computeFn func(context.Context, K1, K2) (V, error), ttl time.Duration) func(context.Context, K1, K2) (V, error) { + cache := NewCache[uint64, V](int64(ttl.Seconds())) + return func(ctx context.Context, key1 K1, key2 K2) (V, error) { + key := hash2(key1, key2) + if v, ok := cache.Get(key); ok { + return v, nil + } + v, err := computeFn(ctx, key1, key2) + if err != nil { + return zeroValue[V](), err + } + cache.Set(key, v) + return v, nil + } +} + +// MemoizeCtx3E memoizes a context-aware function with 3 args returning (V, error). Errors are not cached. +func MemoizeCtx3E[K1, K2, K3 comparable, V any](computeFn func(context.Context, K1, K2, K3) (V, error), ttl time.Duration) func(context.Context, K1, K2, K3) (V, error) { + cache := NewCache[uint64, V](int64(ttl.Seconds())) + return func(ctx context.Context, key1 K1, key2 K2, key3 K3) (V, error) { + key := hash3(key1, key2, key3) + if v, ok := cache.Get(key); ok { + return v, nil + } + v, err := computeFn(ctx, key1, key2, key3) + if err != nil { + return zeroValue[V](), err + } + cache.Set(key, v) + return v, nil + } +} + +// MemoizeCtx4E memoizes a context-aware function with 4 args returning (V, error). Errors are not cached. +func MemoizeCtx4E[K1, K2, K3, K4 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4) (V, error), ttl time.Duration) func(context.Context, K1, K2, K3, K4) (V, error) { + cache := NewCache[uint64, V](int64(ttl.Seconds())) + return func(ctx context.Context, key1 K1, key2 K2, key3 K3, key4 K4) (V, error) { + key := hash4(key1, key2, key3, key4) + if v, ok := cache.Get(key); ok { + return v, nil + } + v, err := computeFn(ctx, key1, key2, key3, key4) + if err != nil { + return zeroValue[V](), err + } + cache.Set(key, v) + return v, nil + } +} + +// MemoizeCtx5E memoizes a context-aware function with 5 args returning (V, error). Errors are not cached. +func MemoizeCtx5E[K1, K2, K3, K4, K5 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4, K5) (V, error), ttl time.Duration) func(context.Context, K1, K2, K3, K4, K5) (V, error) { + cache := NewCache[uint64, V](int64(ttl.Seconds())) + return func(ctx context.Context, key1 K1, key2 K2, key3 K3, key4 K4, key5 K5) (V, error) { + key := hash5(key1, key2, key3, key4, key5) + if v, ok := cache.Get(key); ok { + return v, nil + } + v, err := computeFn(ctx, key1, key2, key3, key4, key5) + if err != nil { + return zeroValue[V](), err + } + cache.Set(key, v) + return v, nil + } +} + +// MemoizeCtx6E memoizes a context-aware function with 6 args returning (V, error). Errors are not cached. +func MemoizeCtx6E[K1, K2, K3, K4, K5, K6 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4, K5, K6) (V, error), ttl time.Duration) func(context.Context, K1, K2, K3, K4, K5, K6) (V, error) { + cache := NewCache[uint64, V](int64(ttl.Seconds())) + return func(ctx context.Context, key1 K1, key2 K2, key3 K3, key4 K4, key5 K5, key6 K6) (V, error) { + key := hash6(key1, key2, key3, key4, key5, key6) + if v, ok := cache.Get(key); ok { + return v, nil + } + v, err := computeFn(ctx, key1, key2, key3, key4, key5, key6) + if err != nil { + return zeroValue[V](), err + } + cache.Set(key, v) + return v, nil + } +} + +// MemoizeCtx7E memoizes a context-aware function with 7 args returning (V, error). Errors are not cached. +func MemoizeCtx7E[K1, K2, K3, K4, K5, K6, K7 comparable, V any](computeFn func(context.Context, K1, K2, K3, K4, K5, K6, K7) (V, error), ttl time.Duration) func(context.Context, K1, K2, K3, K4, K5, K6, K7) (V, error) { + cache := NewCache[uint64, V](int64(ttl.Seconds())) + return func(ctx context.Context, key1 K1, key2 K2, key3 K3, key4 K4, key5 K5, key6 K6, key7 K7) (V, error) { + key := hash7(key1, key2, key3, key4, key5, key6, key7) + if v, ok := cache.Get(key); ok { + return v, nil + } + v, err := computeFn(ctx, key1, key2, key3, key4, key5, key6, key7) + if err != nil { + return zeroValue[V](), err + } + cache.Set(key, v) + return v, nil + } +} diff --git a/memoize_error_test.go b/memoize_error_test.go new file mode 100644 index 0000000..910506b --- /dev/null +++ b/memoize_error_test.go @@ -0,0 +1,173 @@ +package go_memoize + +import ( + "context" + "errors" + "fmt" + "testing" + "time" +) + +func TestMemoize1E_DoesNotCacheError(t *testing.T) { + calls := 0 + fn := func(k int) (string, error) { + calls++ + if calls == 1 { + return "", errors.New("transient failure") + } + return "ok", nil + } + m := Memoize1E(fn, time.Minute) + + // First call should return error and not be cached + if v, err := m(1); err == nil { + t.Fatalf("expected error on first call, got value=%q", v) + } + if calls != 1 { + t.Fatalf("expected 1 call after first invocation, got %d", calls) + } + + // Second call should succeed and be cached + v, err := m(1) + if err != nil { + t.Fatalf("unexpected error on second call: %v", err) + } + if v != "ok" { + t.Fatalf("unexpected value on second call: %q", v) + } + if calls != 2 { + t.Fatalf("expected 2 calls after second invocation, got %d", calls) + } + + // Third call should be served from cache (no additional calls) + v2, err2 := m(1) + if err2 != nil || v2 != "ok" { + t.Fatalf("unexpected result on third call: value=%q err=%v", v2, err2) + } + if calls != 2 { + t.Fatalf("expected no additional compute calls for cached value, got %d", calls) + } +} + +func TestMemoizeE_DoesNotCacheError(t *testing.T) { + calls := 0 + fn := func() (string, error) { + calls++ + if calls == 1 { + return "", errors.New("transient failure") + } + return "ok", nil + } + m := MemoizeE(fn, time.Minute) + + // First call fails and should not be cached + if v, err := m(); err == nil { + t.Fatalf("expected error on first call, got value=%q", v) + } + if calls != 1 { + t.Fatalf("expected 1 call after first invocation, got %d", calls) + } + + // Second call succeeds and should be cached + v, err := m() + if err != nil { + t.Fatalf("unexpected error on second call: %v", err) + } + if v != "ok" { + t.Fatalf("unexpected value on second call: %q", v) + } + if calls != 2 { + t.Fatalf("expected 2 calls after second invocation, got %d", calls) + } + + // Third call should be served from cache + v2, err2 := m() + if err2 != nil || v2 != "ok" { + t.Fatalf("unexpected result on third call: value=%q err=%v", v2, err2) + } + if calls != 2 { + t.Fatalf("expected no additional compute calls for cached value, got %d", calls) + } +} + +func TestMemoize2E_DoesNotCacheError(t *testing.T) { + calls := 0 + fn := func(a int, b string) (string, error) { + calls++ + if calls == 1 { + return "", errors.New("transient failure") + } + return fmt.Sprintf("%d-%s", a, b), nil + } + m := Memoize2E(fn, time.Minute) + + // First call fails and should not be cached + if v, err := m(5, "x"); err == nil { + t.Fatalf("expected error on first call, got value=%q", v) + } + if calls != 1 { + t.Fatalf("expected 1 call after first invocation, got %d", calls) + } + + // Second call succeeds and should be cached + v, err := m(5, "x") + if err != nil { + t.Fatalf("unexpected error on second call: %v", err) + } + if v != "5-x" { + t.Fatalf("unexpected value on second call: %q", v) + } + if calls != 2 { + t.Fatalf("expected 2 calls after second invocation, got %d", calls) + } + + // Third call served from cache + v2, err2 := m(5, "x") + if err2 != nil || v2 != "5-x" { + t.Fatalf("unexpected result on third call: value=%q err=%v", v2, err2) + } + if calls != 2 { + t.Fatalf("expected no additional compute calls for cached value, got %d", calls) + } +} + +func TestMemoizeCtx1E_DoesNotCacheError(t *testing.T) { + calls := 0 + fn := func(ctx context.Context, k int) (string, error) { + calls++ + if calls == 1 { + return "", errors.New("transient failure") + } + return "ok", nil + } + m := MemoizeCtx1E(fn, time.Minute) + + // First call should return error and not be cached + if v, err := m(context.Background(), 42); err == nil { + t.Fatalf("expected error on first call, got value=%q", v) + } + if calls != 1 { + t.Fatalf("expected 1 call after first invocation, got %d", calls) + } + + // Second call should succeed and be cached + v, err := m(context.Background(), 42) + if err != nil { + t.Fatalf("unexpected error on second call: %v", err) + } + if v != "ok" { + t.Fatalf("unexpected value on second call: %q", v) + } + if calls != 2 { + t.Fatalf("expected 2 calls after second invocation, got %d", calls) + } + + // Third call should be served from cache (no additional calls) + v2, err2 := m(context.Background(), 42) + if err2 != nil || v2 != "ok" { + t.Fatalf("unexpected result on third call: value=%q err=%v", v2, err2) + } + if calls != 2 { + t.Fatalf("expected no additional compute calls for cached value, got %d", calls) + } +}