Skip to content

Commit

Permalink
Merge branch 'generics'
Browse files Browse the repository at this point in the history
  • Loading branch information
kofalt committed May 6, 2024
2 parents 0b5d6a3 + 5c7b9f7 commit a2a9717
Show file tree
Hide file tree
Showing 6 changed files with 147 additions and 10 deletions.
2 changes: 1 addition & 1 deletion LICENSE.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2022 Nathaniel Kofalt
Copyright (c) 2024 Nathaniel Kofalt

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
module github.com/kofalt/go-memoize

go 1.19
go 1.22

require (
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/smartystreets/assertions v1.2.0
github.com/smartystreets/gunit v1.4.2
golang.org/x/sync v0.0.0-20220907140024-f12130a52804
golang.org/x/sync v0.7.0
)
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/gunit v1.4.2 h1:tyWYZffdPhQPfK5VsMQXfauwnJkqg7Tv5DLuQVYxq3Q=
github.com/smartystreets/gunit v1.4.2/go.mod h1:ZjM1ozSIMJlAz/ay4SG8PeKF00ckUp+zMHZXV9/bvak=
golang.org/x/sync v0.0.0-20220907140024-f12130a52804 h1:0SH2R3f1b1VmIMG7BXbEZCBUu2dKmHschSmjqGUrW8A=
golang.org/x/sync v0.0.0-20220907140024-f12130a52804/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
38 changes: 38 additions & 0 deletions memoize.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package memoize

import (
"errors"
"time"

"github.com/patrickmn/go-cache"
Expand Down Expand Up @@ -47,3 +48,40 @@ func (m *Memoizer) Memoize(key string, fn func() (interface{}, error)) (interfac
})
return value, err, false
}

// ErrMismatchedType if data returned from the cache does not match the expected type.
var ErrMismatchedType = errors.New("data returned does not match expected type")

// MemoizedFunction the expensive function to be called.
type MemoizedFunction[T any] func() (T, error)

// Call executes and returns the results of the given function, unless there was a cached value of the same key.
// Only one execution is in-flight for a given key at a time.
// The boolean return value indicates whether v was previously stored.
func Call[T any](m *Memoizer, key string, fn MemoizedFunction[T]) (T, error, bool) {
// Check cache
value, found := m.Storage.Get(key)
if found {
v, ok := value.(T)
if !ok {
return v, ErrMismatchedType, true
}
return v, nil, true
}

// Combine memoized function with a cache store
value, err, _ := m.group.Do(key, func() (any, error) {
data, innerErr := fn()

if innerErr == nil {
m.Storage.Set(key, data, cache.DefaultExpiration)
}

return data, innerErr
})
v, ok := value.(T)
if !ok {
return v, ErrMismatchedType, false
}
return v, err, false
}
73 changes: 71 additions & 2 deletions memoize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func (t *F) TestBasic() {
expensiveCalls := 0

// Function tracks how many times its been called
expensive := func() (interface{}, error) {
expensive := func() (any, error) {
expensiveCalls++
return expensiveCalls, nil
}
Expand Down Expand Up @@ -53,7 +53,7 @@ func (t *F) TestFailure() {
calls := 0

// This function will fail IFF it has not been called before.
twoForTheMoney := func() (interface{}, error) {
twoForTheMoney := func() (any, error) {
calls++

if calls == 1 {
Expand Down Expand Up @@ -83,3 +83,72 @@ func (t *F) TestFailure() {
t.So(result.(int), ShouldEqual, 2)
t.So(cached, ShouldBeTrue)
}

// TestBasicGenerics adopts the code from readme.md into a simple test case
// but using generics.
func (t *F) TestBasicGenerics() {
expensiveCalls := 0

// Function tracks how many times its been called
expensive := func() (int, error) {
expensiveCalls++
return expensiveCalls, nil
}

cache := NewMemoizer(90*time.Second, 10*time.Minute)

// First call SHOULD NOT be cached
result, err, cached := Call(cache, "key1", expensive)
t.So(err, ShouldBeNil)
t.So(result, ShouldEqual, 1)
t.So(cached, ShouldBeFalse)

// Second call on same key SHOULD be cached
result, err, cached = Call(cache, "key1", expensive)
t.So(err, ShouldBeNil)
t.So(result, ShouldEqual, 1)
t.So(cached, ShouldBeTrue)

// First call on a new key SHOULD NOT be cached
result, err, cached = Call(cache, "key2", expensive)
t.So(err, ShouldBeNil)
t.So(result, ShouldEqual, 2)
t.So(cached, ShouldBeFalse)
}

// TestFailureGenerics checks that failed function values are not cached
// when using generics.
func (t *F) TestFailureGenerics() {
calls := 0

// This function will fail IFF it has not been called before.
twoForTheMoney := func() (int, error) {
calls++

if calls == 1 {
return calls, errors.New("Try again")
} else {
return calls, nil
}
}

cache := NewMemoizer(90*time.Second, 10*time.Minute)

// First call should fail, and not be cached
result, err, cached := Call(cache, "key1", twoForTheMoney)
t.So(err, ShouldNotBeNil)
t.So(result, ShouldEqual, 1)
t.So(cached, ShouldBeFalse)

// Second call should succeed, and not be cached
result, err, cached = Call(cache, "key1", twoForTheMoney)
t.So(err, ShouldBeNil)
t.So(result, ShouldEqual, 2)
t.So(cached, ShouldBeFalse)

// Third call should succeed, and be cached
result, err, cached = Call(cache, "key1", twoForTheMoney)
t.So(err, ShouldBeNil)
t.So(result, ShouldEqual, 2)
t.So(cached, ShouldBeTrue)
}
36 changes: 33 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import (
)

// Any expensive call that you wish to cache
expensive := func() (interface{}, error) {
expensive := func() (any, error) {
time.Sleep(3 * time.Second)
return "some data", nil
}
Expand All @@ -48,6 +48,36 @@ In the example above, `result` is:
1. the return value from your function if `cached` is false, or
1. a previously stored value if `cached` is true.

All the hard stuff is punted to patrickmn's [go-cache](https://github.com/patrickmn/go-cache) and the Go team's [x/sync/singleflight](https://github.com/golang/sync), I just lashed them together.
All the hard stuff is punted to [go-cache](https://github.com/patrickmn/go-cache) and the [x/sync/singleflight](https://github.com/golang/sync), I just lashed them together.<br/>
Note that `cache.Storage` is exported, so you can use underlying features such as [Flush](https://godoc.org/github.com/patrickmn/go-cache#Cache.Flush) or [SaveFile](https://godoc.org/github.com/patrickmn/go-cache#Cache.SaveFile).

Also note that `cache.Storage` is exported, so you can use the underlying cache features - such as [Flush](https://godoc.org/github.com/patrickmn/go-cache#Cache.Flush) or [SaveFile](https://godoc.org/github.com/patrickmn/go-cache#Cache.SaveFile).
### Type safety

The default usage stores and returns an `any` type.<br/>
If you wants to store & retrieve a specific type, use `Call` instead:

```golang
import (
"time"

"github.com/kofalt/go-memoize"
)

// Same example as above, but this func returns a string!
expensive := func() (string, error) {
time.Sleep(3 * time.Second)
return "some data", nil
}

// Same as before
cache := memoize.NewMemoizer(90*time.Second, 10*time.Minute)

// This will call the expensive func, and return a string.
result, err, cached := memoize.Call(cache, "key1", expensive)

// This will be cached
result, err, cached = memoize.Call(cache, "key1", expensive)

// This uses a new cache key, so expensive is called again
result, err, cached = memoize.Call(cache, "key2", expensive)
```

0 comments on commit a2a9717

Please sign in to comment.