├── .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 | [](https://pkg.go.dev/github.com/kofalt/go-memoize)
8 | [](https://goreportcard.com/report/github.com/kofalt/go-memoize)
9 | [](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 |
--------------------------------------------------------------------------------