├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE.txt ├── go.mod ├── go.sum ├── memoize.go ├── memoize_test.go └── readme.md /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Test 8 | runs-on: ubuntu-latest 9 | steps: 10 | 11 | - uses: actions/checkout@v3 12 | 13 | - uses: actions/setup-go@v3 14 | with: 15 | go-version-file: 'go.mod' 16 | 17 | - run: go mod download 18 | 19 | - run: go build -v 20 | 21 | - run: go test -count 1 ./... 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kofalt/go-memoize/9e5eb99a0f2aeb80e6269c827209520bc7481c1f/.gitignore -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Nathaniel Kofalt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kofalt/go-memoize 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/patrickmn/go-cache v2.1.0+incompatible 7 | github.com/smartystreets/assertions v1.2.0 8 | github.com/smartystreets/gunit v1.4.2 9 | golang.org/x/sync v0.7.0 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= 2 | github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= 3 | github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= 4 | github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= 5 | github.com/smartystreets/gunit v1.4.2 h1:tyWYZffdPhQPfK5VsMQXfauwnJkqg7Tv5DLuQVYxq3Q= 6 | github.com/smartystreets/gunit v1.4.2/go.mod h1:ZjM1ozSIMJlAz/ay4SG8PeKF00ckUp+zMHZXV9/bvak= 7 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 8 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 9 | -------------------------------------------------------------------------------- /memoize.go: -------------------------------------------------------------------------------- 1 | package memoize 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/patrickmn/go-cache" 8 | "golang.org/x/sync/singleflight" 9 | ) 10 | 11 | // Memoizer allows you to memoize function calls. Memoizer is safe for concurrent use by multiple goroutines. 12 | type Memoizer struct { 13 | 14 | // Storage exposes the underlying cache of memoized results to manipulate as desired - for example, to Flush(). 15 | Storage *cache.Cache 16 | 17 | group singleflight.Group 18 | } 19 | 20 | // NewMemoizer creates a new Memoizer with the configured expiry and cleanup policies. 21 | // If desired, use cache.NoExpiration to cache values forever. 22 | func NewMemoizer(defaultExpiration, cleanupInterval time.Duration) *Memoizer { 23 | return &Memoizer{ 24 | Storage: cache.New(defaultExpiration, cleanupInterval), 25 | group: singleflight.Group{}, 26 | } 27 | } 28 | 29 | // Memoize executes and returns the results of the given function, unless there was a cached value of the same key. 30 | // Only one execution is in-flight for a given key at a time. 31 | // The boolean return value indicates whether v was previously stored. 32 | func (m *Memoizer) Memoize(key string, fn func() (interface{}, error)) (interface{}, error, bool) { 33 | // Check cache 34 | value, found := m.Storage.Get(key) 35 | if found { 36 | return value, nil, true 37 | } 38 | 39 | // Combine memoized function with a cache store 40 | value, err, _ := m.group.Do(key, func() (interface{}, error) { 41 | data, innerErr := fn() 42 | 43 | if innerErr == nil { 44 | m.Storage.Set(key, data, cache.DefaultExpiration) 45 | } 46 | 47 | return data, innerErr 48 | }) 49 | return value, err, false 50 | } 51 | 52 | // ErrMismatchedType if data returned from the cache does not match the expected type. 53 | var ErrMismatchedType = errors.New("data returned does not match expected type") 54 | 55 | // MemoizedFunction the expensive function to be called. 56 | type MemoizedFunction[T any] func() (T, error) 57 | 58 | // Call executes and returns the results of the given function, unless there was a cached value of the same key. 59 | // Only one execution is in-flight for a given key at a time. 60 | // The boolean return value indicates whether v was previously stored. 61 | func Call[T any](m *Memoizer, key string, fn MemoizedFunction[T]) (T, error, bool) { 62 | // Check cache 63 | value, found := m.Storage.Get(key) 64 | if found { 65 | v, ok := value.(T) 66 | if !ok { 67 | return v, ErrMismatchedType, true 68 | } 69 | return v, nil, true 70 | } 71 | 72 | // Combine memoized function with a cache store 73 | value, err, _ := m.group.Do(key, func() (any, error) { 74 | data, innerErr := fn() 75 | 76 | if innerErr == nil { 77 | m.Storage.Set(key, data, cache.DefaultExpiration) 78 | } 79 | 80 | return data, innerErr 81 | }) 82 | v, ok := value.(T) 83 | if !ok { 84 | return v, ErrMismatchedType, false 85 | } 86 | return v, err, false 87 | } 88 | -------------------------------------------------------------------------------- /memoize_test.go: -------------------------------------------------------------------------------- 1 | package memoize 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | "time" 7 | 8 | . "github.com/smartystreets/assertions" 9 | "github.com/smartystreets/gunit" 10 | ) 11 | 12 | func TestSuite(t *testing.T) { 13 | gunit.Run(new(F), t) 14 | } 15 | 16 | type F struct { 17 | *gunit.Fixture 18 | } 19 | 20 | // TestBasic adopts the code from readme.md into a simple test case 21 | func (t *F) TestBasic() { 22 | expensiveCalls := 0 23 | 24 | // Function tracks how many times its been called 25 | expensive := func() (any, error) { 26 | expensiveCalls++ 27 | return expensiveCalls, nil 28 | } 29 | 30 | cache := NewMemoizer(90*time.Second, 10*time.Minute) 31 | 32 | // First call SHOULD NOT be cached 33 | result, err, cached := cache.Memoize("key1", expensive) 34 | t.So(err, ShouldBeNil) 35 | t.So(result.(int), ShouldEqual, 1) 36 | t.So(cached, ShouldBeFalse) 37 | 38 | // Second call on same key SHOULD be cached 39 | result, err, cached = cache.Memoize("key1", expensive) 40 | t.So(err, ShouldBeNil) 41 | t.So(result.(int), ShouldEqual, 1) 42 | t.So(cached, ShouldBeTrue) 43 | 44 | // First call on a new key SHOULD NOT be cached 45 | result, err, cached = cache.Memoize("key2", expensive) 46 | t.So(err, ShouldBeNil) 47 | t.So(result.(int), ShouldEqual, 2) 48 | t.So(cached, ShouldBeFalse) 49 | } 50 | 51 | // TestFailure checks that failed function values are not cached 52 | func (t *F) TestFailure() { 53 | calls := 0 54 | 55 | // This function will fail IFF it has not been called before. 56 | twoForTheMoney := func() (any, error) { 57 | calls++ 58 | 59 | if calls == 1 { 60 | return calls, errors.New("Try again") 61 | } else { 62 | return calls, nil 63 | } 64 | } 65 | 66 | cache := NewMemoizer(90*time.Second, 10*time.Minute) 67 | 68 | // First call should fail, and not be cached 69 | result, err, cached := cache.Memoize("key1", twoForTheMoney) 70 | t.So(err, ShouldNotBeNil) 71 | t.So(result.(int), ShouldEqual, 1) 72 | t.So(cached, ShouldBeFalse) 73 | 74 | // Second call should succeed, and not be cached 75 | result, err, cached = cache.Memoize("key1", twoForTheMoney) 76 | t.So(err, ShouldBeNil) 77 | t.So(result.(int), ShouldEqual, 2) 78 | t.So(cached, ShouldBeFalse) 79 | 80 | // Third call should succeed, and be cached 81 | result, err, cached = cache.Memoize("key1", twoForTheMoney) 82 | t.So(err, ShouldBeNil) 83 | t.So(result.(int), ShouldEqual, 2) 84 | t.So(cached, ShouldBeTrue) 85 | } 86 | 87 | // TestBasicGenerics adopts the code from readme.md into a simple test case 88 | // but using generics. 89 | func (t *F) TestBasicGenerics() { 90 | expensiveCalls := 0 91 | 92 | // Function tracks how many times its been called 93 | expensive := func() (int, error) { 94 | expensiveCalls++ 95 | return expensiveCalls, nil 96 | } 97 | 98 | cache := NewMemoizer(90*time.Second, 10*time.Minute) 99 | 100 | // First call SHOULD NOT be cached 101 | result, err, cached := Call(cache, "key1", expensive) 102 | t.So(err, ShouldBeNil) 103 | t.So(result, ShouldEqual, 1) 104 | t.So(cached, ShouldBeFalse) 105 | 106 | // Second call on same key SHOULD be cached 107 | result, err, cached = Call(cache, "key1", expensive) 108 | t.So(err, ShouldBeNil) 109 | t.So(result, ShouldEqual, 1) 110 | t.So(cached, ShouldBeTrue) 111 | 112 | // First call on a new key SHOULD NOT be cached 113 | result, err, cached = Call(cache, "key2", expensive) 114 | t.So(err, ShouldBeNil) 115 | t.So(result, ShouldEqual, 2) 116 | t.So(cached, ShouldBeFalse) 117 | } 118 | 119 | // TestFailureGenerics checks that failed function values are not cached 120 | // when using generics. 121 | func (t *F) TestFailureGenerics() { 122 | calls := 0 123 | 124 | // This function will fail IFF it has not been called before. 125 | twoForTheMoney := func() (int, error) { 126 | calls++ 127 | 128 | if calls == 1 { 129 | return calls, errors.New("Try again") 130 | } else { 131 | return calls, nil 132 | } 133 | } 134 | 135 | cache := NewMemoizer(90*time.Second, 10*time.Minute) 136 | 137 | // First call should fail, and not be cached 138 | result, err, cached := Call(cache, "key1", twoForTheMoney) 139 | t.So(err, ShouldNotBeNil) 140 | t.So(result, ShouldEqual, 1) 141 | t.So(cached, ShouldBeFalse) 142 | 143 | // Second call should succeed, and not be cached 144 | result, err, cached = Call(cache, "key1", twoForTheMoney) 145 | t.So(err, ShouldBeNil) 146 | t.So(result, ShouldEqual, 2) 147 | t.So(cached, ShouldBeFalse) 148 | 149 | // Third call should succeed, and be cached 150 | result, err, cached = Call(cache, "key1", twoForTheMoney) 151 | t.So(err, ShouldBeNil) 152 | t.So(result, ShouldEqual, 2) 153 | t.So(cached, ShouldBeTrue) 154 | } 155 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # go-memoize 2 | 3 | There wasn't a decent [memoizer](https://wikipedia.org/wiki/Memoization) for Golang out there, so I lashed two nice libraries together and made one. 4 | 5 | Dead-simple. Safe for concurrent use. 6 | 7 | [![Reference](http://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](https://pkg.go.dev/github.com/kofalt/go-memoize) 8 | [![Linter](https://goreportcard.com/badge/github.com/kofalt/go-memoize?style=flat-square)](https://goreportcard.com/report/github.com/kofalt/go-memoize) 9 | [![Build status](https://github.com/kofalt/go-memoize/workflows/Build/badge.svg)](https://github.com/kofalt/go-memoize/actions) 10 | 11 | ## Project status 12 | 13 | **Complete.** Latest commit timestamp might be old - that's okay. 14 | 15 | Go-memoize has been in production for a few years, and has yet to burn the house down. 16 | 17 | ## Usage 18 | 19 | Cache expensive function calls in memory, with a configurable timeout and purge interval: 20 | 21 | ```golang 22 | import ( 23 | "time" 24 | 25 | "github.com/kofalt/go-memoize" 26 | ) 27 | 28 | // Any expensive call that you wish to cache 29 | expensive := func() (any, error) { 30 | time.Sleep(3 * time.Second) 31 | return "some data", nil 32 | } 33 | 34 | // Cache expensive calls in memory for 90 seconds, purging old entries every 10 minutes. 35 | cache := memoize.NewMemoizer(90*time.Second, 10*time.Minute) 36 | 37 | // This will call the expensive func 38 | result, err, cached := cache.Memoize("key1", expensive) 39 | 40 | // This will be cached 41 | result, err, cached = cache.Memoize("key1", expensive) 42 | 43 | // This uses a new cache key, so expensive is called again 44 | result, err, cached = cache.Memoize("key2", expensive) 45 | ``` 46 | 47 | In the example above, `result` is: 48 | 1. the return value from your function if `cached` is false, or 49 | 1. a previously stored value if `cached` is true. 50 | 51 | All the hard stuff is punted to [go-cache](https://github.com/patrickmn/go-cache) and [sync/singleflight](https://github.com/golang/sync), I just lashed them together.
52 | 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). 53 | 54 | ### Type safety 55 | 56 | The default usage stores and returns an `any` type.
57 | If you wants to store & retrieve a specific type, use `Call` instead: 58 | 59 | ```golang 60 | import ( 61 | "time" 62 | 63 | "github.com/kofalt/go-memoize" 64 | ) 65 | 66 | // Same example as above, but this func returns a string! 67 | expensive := func() (string, error) { 68 | time.Sleep(3 * time.Second) 69 | return "some data", nil 70 | } 71 | 72 | // Same as before 73 | cache := memoize.NewMemoizer(90*time.Second, 10*time.Minute) 74 | 75 | // This will call the expensive func, and return a string. 76 | result, err, cached := memoize.Call(cache, "key1", expensive) 77 | 78 | // This will be cached 79 | result, err, cached = memoize.Call(cache, "key1", expensive) 80 | 81 | // This uses a new cache key, so expensive is called again 82 | result, err, cached = memoize.Call(cache, "key2", expensive) 83 | ``` 84 | --------------------------------------------------------------------------------