├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── bench.sh
├── bench_test.go
├── docs
├── graphs_shows_s3_fifo_is_powerful.png
└── zipf_law_discovered_by_realworld_traces.png
├── go.mod
├── go.sum
├── s3fifo
├── ghost.go
├── s3fifo.go
└── s3fifo_test.go
├── sieve
├── sieve.go
└── sieve_test.go
└── types
└── types.go
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: build
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 |
8 | jobs:
9 | test:
10 | name: Test with Coverage
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Set up Go
14 | uses: actions/setup-go@v4
15 | with:
16 | go-version: '1.24.x'
17 | - name: Check out code
18 | uses: actions/checkout@v4
19 | - name: Install dependencies
20 | run: |
21 | go mod download
22 | - name: Run Unit tests
23 | run: |
24 | go test -race -covermode atomic -coverprofile=covprofile ./...
25 | - name: Install goveralls
26 | run: go install github.com/mattn/goveralls@latest
27 | - name: Send coverage
28 | env:
29 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30 | run: goveralls -coverprofile=covprofile -service=github
31 | build:
32 | runs-on: ubuntu-latest
33 | steps:
34 | - name: checkout
35 | uses: actions/checkout@v4
36 | - name: Setup Go 1.24.x
37 | uses: actions/setup-go@v4
38 | with:
39 | go-version: '1.24.x'
40 | - name: Build
41 | run: go build -v ./...
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | golang-fifo.test
2 | out
3 |
4 | # IDE
5 | .idea
6 | .vscode
7 | .vs
8 |
9 | # OS
10 | .DS_Store
11 | Thumbs.db
12 |
13 | # If you prefer the allow list template instead of the deny list, see community template:
14 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
15 | #
16 | # Binaries for programs and plugins
17 | *.exe
18 | *.exe~
19 | *.dll
20 | *.so
21 | *.dylib
22 |
23 | # Test binary, built with `go test -c`
24 | *.test
25 |
26 | # Output of the go coverage tool, specifically when used with LiteIDE
27 | *.out
28 |
29 | # Dependency directories (remove the comment below to include it)
30 | # vendor/
31 |
32 | # Go workspace file
33 | go.work
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # CHANGELOG
2 |
3 | ## v1.0.0
4 |
5 | January 19, 2024
6 |
7 | This release will include the following changes:
8 | - Add an entry expiration in the cache
9 | - Add GC-optimized cache implementation
10 | - Change implementation of the bucket table in the cache.
11 |
12 | ### BREAKING CHANGES
13 | - Add expiration policy. [\#27](https://github.com/scalalang2/golang-fifo/pull/27) by @scalalang2
14 |
15 | ### FEATURES
16 | - Add expiration policy. [\#27](https://github.com/scalalang2/golang-fifo/pull/27) by @scalalang2
17 |
18 | ### IMPROVEMENTS
19 | - Bump up go version to 1.22. [\#26](https://github.com/scalalang2/golang-fifo/pull/26) by @scalalang2
20 |
21 | ### BUG FIXES
22 | - Fix a race condition issue in the SIEVE cache. [\#23](https://github.com/scalalang2/golang-fifo/pull/23) by @scalalang2
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 scalalang2
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://pkg.go.dev/github.com/scalalang2/golang-fifo)
2 | [](https://goreportcard.com/report/github.com/scalalang2/golang-fifo)
3 | 
4 | [](https://coveralls.io/github/scalalang2/golang-fifo?branch=main)
5 |
6 |
golang-fifo
7 |
8 | This is a modern cache implementation, **inspired** by the following papers, provides high efficiency.
9 |
10 | - **SIEVE** | [SIEVE is Simpler than LRU: an Efficient Turn-Key Eviction Algorithm for Web Caches](https://junchengyang.com/publication/nsdi24-SIEVE.pdf) (NSDI'24)
11 | - **S3-FIFO** | [FIFO queues are all you need for cache eviction](https://dl.acm.org/doi/10.1145/3600006.3613147) (SOSP'23)
12 |
13 | This offers state-of-the-art efficiency and scalability compared to other LRU-based cache algorithms.
14 |
15 | ## Basic Usage
16 | ```go
17 | import "github.com/scalalang2/golang-fifo/sieve"
18 |
19 | size := 1e5
20 | ttl := 0 // 0 means no expiration
21 | cache := sieve.New[string, string](size, ttl)
22 |
23 | // set value under hello
24 | cache.Set("hello", "world")
25 |
26 | // get value under hello
27 | val, ok := cache.Get("hello")
28 | if ok {
29 | fmt.Printf("value: %s", val) // => "world"
30 | }
31 |
32 | // set more keys
33 | for i := 0; i < 10; i++ {
34 | cache.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i))
35 | }
36 |
37 | // get number of cache entries
38 | fmt.Printf("len: %d", cache.Len()) // => 11
39 |
40 | // remove value under hello
41 | removed := cache.Remove("hello")
42 | if removed {
43 | fmt.Println("hello was removed")
44 | }
45 | ```
46 |
47 | ## Expiry
48 | ```go
49 | import "github.com/scalalang2/golang-fifo/sieve"
50 |
51 | size := 1e5
52 | ttl := time.Second * 3600 // 1 hour
53 | cache := sieve.New[string, string](size, ttl)
54 |
55 | // this callback will be called when the element is expired
56 | cahe.SetOnEvict(func(key string, value string) {
57 | fmt.Printf("key: %s, value: %s was evicted", key, value)
58 | })
59 |
60 | // set value under hello
61 | cache.Set("hello", "world")
62 |
63 | // remove all cache entries and stop the eviction goroutine.
64 | cache.Close()
65 | ```
66 |
67 | ## Benchmark Result
68 | The benchmark result were obtained using [go-cache-benchmark](https://github.com/scalalang2/go-cache-benchmark)
69 |
70 | ```
71 | itemSize=500000, workloads=7500000, cacheSize=0.10%, zipf's alpha=0.99, concurrency=16
72 |
73 | CACHE | HITRATE | QPS | HITS | MISSES
74 | -----------------+---------+---------+---------+----------
75 | sieve | 47.66% | 2508361 | 3574212 | 3925788
76 | tinylfu | 47.37% | 2269542 | 3552921 | 3947079
77 | s3-fifo | 47.17% | 1651619 | 3538121 | 3961879
78 | slru | 46.49% | 2201350 | 3486476 | 4013524
79 | s4lru | 46.09% | 2484266 | 3456682 | 4043318
80 | two-queue | 45.49% | 1713502 | 3411800 | 4088200
81 | clock | 37.34% | 2370417 | 2800750 | 4699250
82 | lru-groupcache | 36.59% | 2206841 | 2743894 | 4756106
83 | lru-hashicorp | 36.57% | 2055358 | 2743000 | 4757000
84 | ```
85 |
86 | **SIEVE** delivers both high hit rates and the highest QPS(queries per seconds) compared to other LRU-based caches.
87 | Additionally, It approximately improves 30% for efficiency than a simple LRU cache.
88 |
89 | Increasing efficiency means not only reducing cache misses,
90 | but also reducing the demand for heavy operations such as backend database access, which lowers the mean latency.
91 |
92 | While LRU promotes accessed objects to the head of the queue,
93 | requiring a potentially slow lock acquisition,
94 | SIEVE only needs to update a single bit upon a cache hit.
95 | This update can be done with a significantly faster reader lock, leading to increased performance.
96 |
97 | The real-world traces are also evaluated at [here](https://observablehq.com/@1a1a11a/sieve-miss-ratio-plots)
98 |
99 | ## Appendix
100 |
101 |
102 | Performance : golang-fifo
103 |
104 | ```shell
105 | goos: linux
106 | goarch: amd64
107 | pkg: github.com/scalalang2/golang-fifo
108 | cpu: Intel(R) Core(TM) i5-10600KF CPU @ 4.10GHz
109 | BenchmarkCache
110 | BenchmarkCache/cache=sieve
111 | BenchmarkCache/cache=sieve/t=int32
112 | BenchmarkCache/cache=sieve/t=int32-12 2765682 393.8 ns/op 148 B/op 4 allocs/op
113 | BenchmarkCache/cache=sieve/t=int32-12 3037669 388.1 ns/op 149 B/op 4 allocs/op
114 | BenchmarkCache/cache=sieve/t=int32-12 3075998 395.0 ns/op 149 B/op 4 allocs/op
115 | BenchmarkCache/cache=sieve/t=int32-12 2924646 392.0 ns/op 148 B/op 4 allocs/op
116 | BenchmarkCache/cache=sieve/t=int32-12 2632326 409.3 ns/op 148 B/op 4 allocs/op
117 | BenchmarkCache/cache=sieve/t=int32-12 2746551 463.5 ns/op 148 B/op 4 allocs/op
118 | BenchmarkCache/cache=sieve/t=int32-12 3004071 401.0 ns/op 148 B/op 4 allocs/op
119 | BenchmarkCache/cache=sieve/t=int32-12 2398981 456.0 ns/op 149 B/op 4 allocs/op
120 | BenchmarkCache/cache=sieve/t=int32-12 2698939 422.9 ns/op 148 B/op 4 allocs/op
121 | BenchmarkCache/cache=sieve/t=int32-12 2647030 392.1 ns/op 148 B/op 4 allocs/op
122 | BenchmarkCache/cache=sieve/t=int64
123 | BenchmarkCache/cache=sieve/t=int64-12 2532614 414.1 ns/op 158 B/op 4 allocs/op
124 | BenchmarkCache/cache=sieve/t=int64-12 2825973 419.3 ns/op 158 B/op 4 allocs/op
125 | BenchmarkCache/cache=sieve/t=int64-12 2693790 407.1 ns/op 158 B/op 4 allocs/op
126 | BenchmarkCache/cache=sieve/t=int64-12 2882792 414.7 ns/op 157 B/op 4 allocs/op
127 | BenchmarkCache/cache=sieve/t=int64-12 2903197 421.7 ns/op 157 B/op 4 allocs/op
128 | BenchmarkCache/cache=sieve/t=int64-12 2876046 435.7 ns/op 157 B/op 4 allocs/op
129 | BenchmarkCache/cache=sieve/t=int64-12 2846494 410.4 ns/op 157 B/op 4 allocs/op
130 | BenchmarkCache/cache=sieve/t=int64-12 2455807 440.1 ns/op 158 B/op 4 allocs/op
131 | BenchmarkCache/cache=sieve/t=int64-12 2774462 435.1 ns/op 158 B/op 4 allocs/op
132 | BenchmarkCache/cache=sieve/t=int64-12 2833150 433.9 ns/op 157 B/op 4 allocs/op
133 | BenchmarkCache/cache=sieve/t=string
134 | BenchmarkCache/cache=sieve/t=string-12 2117859 546.9 ns/op 186 B/op 4 allocs/op
135 | BenchmarkCache/cache=sieve/t=string-12 2079752 527.1 ns/op 186 B/op 4 allocs/op
136 | BenchmarkCache/cache=sieve/t=string-12 2210930 530.8 ns/op 186 B/op 4 allocs/op
137 | BenchmarkCache/cache=sieve/t=string-12 2122942 514.4 ns/op 186 B/op 4 allocs/op
138 | BenchmarkCache/cache=sieve/t=string-12 2222488 553.6 ns/op 186 B/op 4 allocs/op
139 | BenchmarkCache/cache=sieve/t=string-12 2260266 558.6 ns/op 186 B/op 4 allocs/op
140 | BenchmarkCache/cache=sieve/t=string-12 2239196 567.1 ns/op 186 B/op 4 allocs/op
141 | BenchmarkCache/cache=sieve/t=string-12 2064308 576.8 ns/op 186 B/op 4 allocs/op
142 | BenchmarkCache/cache=sieve/t=string-12 1882754 569.9 ns/op 185 B/op 4 allocs/op
143 | BenchmarkCache/cache=sieve/t=string-12 1917342 574.6 ns/op 185 B/op 4 allocs/op
144 | BenchmarkCache/cache=sieve/t=composite
145 | BenchmarkCache/cache=sieve/t=composite-12 1825063 707.0 ns/op 223 B/op 4 allocs/op
146 | BenchmarkCache/cache=sieve/t=composite-12 1745775 660.1 ns/op 224 B/op 4 allocs/op
147 | BenchmarkCache/cache=sieve/t=composite-12 1680552 678.1 ns/op 225 B/op 4 allocs/op
148 | BenchmarkCache/cache=sieve/t=composite-12 1774438 690.1 ns/op 224 B/op 4 allocs/op
149 | BenchmarkCache/cache=sieve/t=composite-12 1530580 731.1 ns/op 226 B/op 4 allocs/op
150 | BenchmarkCache/cache=sieve/t=composite-12 1663950 761.7 ns/op 225 B/op 4 allocs/op
151 | BenchmarkCache/cache=sieve/t=composite-12 1607760 678.4 ns/op 225 B/op 4 allocs/op
152 | BenchmarkCache/cache=sieve/t=composite-12 1703283 784.4 ns/op 225 B/op 4 allocs/op
153 | BenchmarkCache/cache=sieve/t=composite-12 1295089 864.6 ns/op 229 B/op 4 allocs/op
154 | BenchmarkCache/cache=sieve/t=composite-12 1552182 769.9 ns/op 226 B/op 4 allocs/op
155 | ```
156 |
157 |
158 |
159 | Why LRU Cache is not good enough?
160 |
161 | - LRU is often implemented with a doubly linked list and a hash table, requiring two pointers per cache entry,
162 | which becomes large overhead when the object is small.
163 | - It promotes objects to the head of the queue upon cache hit, which performs at least six random memory accesses
164 | protected by lock, which limits the scalability.
165 |
166 |
167 |
168 | Things to consider before adoption
169 |
170 | - Both **S3-FIFO** and **SIEVE** have a O(n) time complexity for cache eviction,
171 | which only occurs when all objects are hit the cache, which means that there is a perfect (100%) hit rate in the cache.
172 | - **SIEVE** is not designed to be scan-resistant. Therefore, it's currently recommended for web cache workloads,
173 | which typically follow a power-law distribution.
174 | - **S3-FIFO** filters out one-hit-wonders early, It bears some resemblance to designing scan-resistant cache eviction algorithms.
175 | - **SIEVE** scales well for read-intensive applications such as blogs and online shops, because it doesn't require to hold a writer lock on cache hit.
176 | - The `golang-fifo` library aims to provide a straightforward and efficient cache implementation,
177 | similar to [hashicorp-lru](https://github.com/hashicorp/golang-lru) and [groupcache](https://github.com/golang/groupcache).
178 | Its goal is not to outperform highly specialized in-memory cache libraries (e.g. [bigcache](https://github.com/allegro/bigcache), [freecache](https://github.com/coocood/freecache) and etc).
179 |
180 |
181 |
182 | Brief overview of SIEVE & S3-FIFO
183 |
184 | Various workloads typically follows **Power law distribution (e.g. Zipf's law)** as shown in the following figure.
185 |
186 | 
187 |
188 | The analysis reveals that most requests are "one-hit-wonders", which means it's accessed only once.
189 | Consequently, a cache eviction strategy should quickly remove most objects after insertion.
190 |
191 | **S3-FIFO** and **SIEVE** achieves this goal with simplicity, efficiency, and scalability using simple FIFO queue only.
192 |
193 | 
194 |
195 |
196 | ## Contribution
197 | How to run unit test
198 | ```bash
199 | $ go test -v ./...
200 | ```
201 |
202 | How to run benchmark test
203 | ```bash
204 | $ ./bench.sh
205 | ```
206 |
--------------------------------------------------------------------------------
/bench.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | go test -c
4 | ./golang-fifo.test -test.v -test.run - -test.bench . -test.count 10 -test.benchmem -test.timeout 10h | tee out
--------------------------------------------------------------------------------
/bench_test.go:
--------------------------------------------------------------------------------
1 | package golang_fifo
2 |
3 | import (
4 | "strconv"
5 | "testing"
6 |
7 | "github.com/scalalang2/golang-fifo/sieve"
8 | )
9 |
10 | type value struct {
11 | bytes []byte
12 | }
13 |
14 | type compositeKey struct {
15 | key1 string
16 | key2 string
17 | }
18 |
19 | type benchTypes interface {
20 | int32 | int64 | string | compositeKey
21 | }
22 |
23 | func BenchmarkCache(b *testing.B) {
24 | b.Run("cache=sieve", func(b *testing.B) {
25 | b.Run("t=int32", bench[int32](genKeysInt32))
26 | b.Run("t=int64", bench[int64](genKeysInt64))
27 | b.Run("t=string", bench[string](genKeysString))
28 | b.Run("t=composite", bench[compositeKey](genKeysComposite))
29 | })
30 | }
31 |
32 | func bench[T benchTypes](gen func(workload int) []T) func(b *testing.B) {
33 | cacheSize := 100000
34 |
35 | return func(b *testing.B) {
36 | benchmarkSieveCache[T](b, cacheSize, gen)
37 | }
38 | }
39 |
40 | func genKeysInt32(workload int) []int32 {
41 | keys := make([]int32, workload)
42 | for i := range keys {
43 | keys[i] = int32(i)
44 | }
45 | return keys
46 | }
47 |
48 | func genKeysInt64(workload int) []int64 {
49 | keys := make([]int64, workload)
50 | for i := range keys {
51 | keys[i] = int64(i)
52 | }
53 | return keys
54 | }
55 |
56 | func genKeysString(workload int) []string {
57 | keys := make([]string, workload)
58 | for i := range keys {
59 | keys[i] = strconv.Itoa(i)
60 | }
61 | return keys
62 | }
63 |
64 | func genKeysComposite(workload int) []compositeKey {
65 | keys := make([]compositeKey, workload)
66 | for i := range keys {
67 | keys[i].key1 = strconv.Itoa(i)
68 | keys[i].key2 = strconv.Itoa(i)
69 | }
70 | return keys
71 | }
72 |
73 | func benchmarkSieveCache[T benchTypes](b *testing.B, cacheSize int, genKey func(size int) []T) {
74 | cache := sieve.New[T, value](cacheSize, 0)
75 | keys := genKey(b.N)
76 | b.ResetTimer()
77 | for i := 0; i < b.N; i++ {
78 | key := keys[i]
79 | cache.Set(key, value{
80 | bytes: make([]byte, 10),
81 | })
82 | cache.Get(key)
83 | }
84 | cache.Purge()
85 | }
86 |
--------------------------------------------------------------------------------
/docs/graphs_shows_s3_fifo_is_powerful.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/scalalang2/golang-fifo/fe92c272c95a3187039cf62047e98de342ad022a/docs/graphs_shows_s3_fifo_is_powerful.png
--------------------------------------------------------------------------------
/docs/zipf_law_discovered_by_realworld_traces.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/scalalang2/golang-fifo/fe92c272c95a3187039cf62047e98de342ad022a/docs/zipf_law_discovered_by_realworld_traces.png
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/scalalang2/golang-fifo
2 |
3 | go 1.24
4 |
5 | require fortio.org/assert v1.2.1
6 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | fortio.org/assert v1.2.1 h1:48I39urpeDj65RP1KguF7akCjILNeu6vICiYMEysR7Q=
2 | fortio.org/assert v1.2.1/go.mod h1:039mG+/iYDPO8Ibx8TrNuJCm2T2SuhwRI3uL9nHTTls=
3 |
--------------------------------------------------------------------------------
/s3fifo/ghost.go:
--------------------------------------------------------------------------------
1 | package s3fifo
2 |
3 | import "container/list"
4 |
5 | // ghost represents the `ghost` structure in the S3FIFO algorithm.
6 | // At the time of writing, this implementation is not carefully designed.
7 | // There can be a better way to implement `ghost`.
8 | type ghost[K comparable] struct {
9 | size int
10 | ll *list.List
11 | items map[K]*list.Element
12 | }
13 |
14 | func newGhost[K comparable](size int) *ghost[K] {
15 | return &ghost[K]{
16 | size: size,
17 | ll: list.New(),
18 | items: make(map[K]*list.Element),
19 | }
20 | }
21 |
22 | func (b *ghost[K]) add(key K) {
23 | if _, ok := b.items[key]; ok {
24 | return
25 | }
26 |
27 | for b.ll.Len() >= b.size {
28 | e := b.ll.Back()
29 | delete(b.items, e.Value.(K))
30 | b.ll.Remove(e)
31 | }
32 |
33 | e := b.ll.PushFront(key)
34 | b.items[key] = e
35 | }
36 |
37 | func (b *ghost[K]) remove(key K) {
38 | if e, ok := b.items[key]; ok {
39 | b.ll.Remove(e)
40 | delete(b.items, key)
41 | }
42 | }
43 |
44 | func (b *ghost[K]) contains(key K) bool {
45 | _, ok := b.items[key]
46 | return ok
47 | }
48 |
49 | func (b *ghost[K]) clear() {
50 | b.ll.Init()
51 | for k := range b.items {
52 | delete(b.items, k)
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/s3fifo/s3fifo.go:
--------------------------------------------------------------------------------
1 | package s3fifo
2 |
3 | import (
4 | "container/list"
5 | "context"
6 | "sync"
7 | "time"
8 |
9 | "github.com/scalalang2/golang-fifo/types"
10 | )
11 |
12 | const numberOfBuckets = 100
13 |
14 | // entry holds the key and value of a cache entry.
15 | type entry[K comparable, V any] struct {
16 | key K
17 | value V
18 | freq byte
19 | element *list.Element
20 | expiredAt time.Time
21 | bucketID int8 // bucketID is an index which the entry is stored in the bucket
22 | }
23 |
24 | // bucket is a container holding entries to be expired
25 | // ref. hashicorp/golang-lru
26 | type bucket[K comparable, V any] struct {
27 | entries map[K]*entry[K, V]
28 | newestEntry time.Time
29 | }
30 |
31 | type S3FIFO[K comparable, V any] struct {
32 | ctx context.Context
33 | cancel context.CancelFunc
34 | mu sync.Mutex
35 |
36 | // size is the maximum number of entries in the cache.
37 | size int
38 |
39 | // followings are the fundamental data structures of S3FIFO algorithm.
40 | items map[K]*entry[K, V]
41 | small *list.List
42 | main *list.List
43 | ghost *ghost[K]
44 |
45 | buckets []bucket[K, V]
46 |
47 | // ttl is the time to live of the cache entry
48 | ttl time.Duration
49 |
50 | // nextCleanupBucket is an index of the next bucket to be cleaned up
51 | nextCleanupBucket int8
52 |
53 | // callback is the function that will be called when an entry is evicted from the cache
54 | callback types.OnEvictCallback[K, V]
55 | }
56 |
57 | var _ types.Cache[int, int] = (*S3FIFO[int, int])(nil)
58 |
59 | func New[K comparable, V any](size int, ttl time.Duration) *S3FIFO[K, V] {
60 | ctx, cancel := context.WithCancel(context.Background())
61 |
62 | if ttl <= 0 {
63 | ttl = 0
64 | }
65 |
66 | cache := &S3FIFO[K, V]{
67 | ctx: ctx,
68 | cancel: cancel,
69 | size: size,
70 | items: make(map[K]*entry[K, V]),
71 | small: list.New(),
72 | main: list.New(),
73 | ghost: newGhost[K](size),
74 | buckets: make([]bucket[K, V], numberOfBuckets),
75 | ttl: ttl,
76 | nextCleanupBucket: 0,
77 | }
78 |
79 | for i := 0; i < numberOfBuckets; i++ {
80 | cache.buckets[i].entries = make(map[K]*entry[K, V])
81 | }
82 |
83 | if ttl != 0 {
84 | go func(ctx context.Context) {
85 | ticker := time.NewTicker(ttl / numberOfBuckets)
86 | defer ticker.Stop()
87 | for {
88 | select {
89 | case <-ctx.Done():
90 | return
91 | case <-ticker.C:
92 | cache.deleteExpired()
93 | }
94 | }
95 | }(cache.ctx)
96 | }
97 |
98 | return cache
99 | }
100 |
101 | func (s *S3FIFO[K, V]) Set(key K, value V) {
102 | s.mu.Lock()
103 | defer s.mu.Unlock()
104 |
105 | if el, ok := s.items[key]; ok {
106 | s.removeFromBucket(el) // remove from the bucket as the entry is updated
107 | el.value = value
108 | el.freq = min(el.freq+1, 3)
109 | el.expiredAt = time.Now().Add(s.ttl)
110 | s.addToBucket(el)
111 | return
112 | }
113 |
114 | for s.small.Len()+s.main.Len() >= s.size {
115 | s.evict()
116 | }
117 |
118 | // create a new entry to append it to the cache.
119 | ent := &entry[K, V]{
120 | key: key,
121 | value: value,
122 | freq: 0,
123 | expiredAt: time.Now().Add(s.ttl),
124 | }
125 |
126 | if s.ghost.contains(key) {
127 | s.ghost.remove(key)
128 | ent.element = s.main.PushFront(key)
129 | } else {
130 | ent.element = s.small.PushFront(key)
131 | }
132 |
133 | s.items[key] = ent
134 | s.addToBucket(ent)
135 | }
136 |
137 | func (s *S3FIFO[K, V]) Get(key K) (value V, ok bool) {
138 | s.mu.Lock()
139 | defer s.mu.Unlock()
140 |
141 | if _, ok := s.items[key]; !ok {
142 | return value, false
143 | }
144 |
145 | s.items[key].freq = min(s.items[key].freq+1, 3)
146 | s.ghost.remove(key)
147 | return s.items[key].value, true
148 | }
149 |
150 | func (s *S3FIFO[K, V]) Remove(key K) (ok bool) {
151 | s.mu.Lock()
152 | defer s.mu.Unlock()
153 | if e, ok := s.items[key]; ok {
154 | s.removeEntry(e, types.EvictReasonRemoved)
155 | return true
156 | }
157 |
158 | return false
159 | }
160 |
161 | func (s *S3FIFO[K, V]) Contains(key K) (ok bool) {
162 | s.mu.Lock()
163 | defer s.mu.Unlock()
164 |
165 | if _, ok := s.items[key]; ok {
166 | return true
167 | }
168 | return false
169 | }
170 |
171 | func (s *S3FIFO[K, V]) Peek(key K) (value V, ok bool) {
172 | s.mu.Lock()
173 | defer s.mu.Unlock()
174 |
175 | el, ok := s.items[key]
176 | if !ok {
177 | return value, false
178 | }
179 | return el.value, ok
180 | }
181 |
182 | func (s *S3FIFO[K, V]) SetOnEvicted(callback types.OnEvictCallback[K, V]) {
183 | s.mu.Lock()
184 | defer s.mu.Unlock()
185 |
186 | s.callback = callback
187 | }
188 |
189 | func (s *S3FIFO[K, V]) Len() int {
190 | return s.small.Len() + s.main.Len()
191 | }
192 |
193 | func (s *S3FIFO[K, V]) Purge() {
194 | s.mu.Lock()
195 | defer s.mu.Unlock()
196 |
197 | for k := range s.items {
198 | delete(s.items, k)
199 | }
200 |
201 | s.small.Init()
202 | s.main.Init()
203 | s.ghost.clear()
204 | }
205 |
206 | func (s *S3FIFO[K, V]) Close() {
207 | s.Purge()
208 | s.mu.Lock()
209 | s.cancel()
210 | s.mu.Unlock()
211 | }
212 |
213 | func (s *S3FIFO[K, V]) removeEntry(e *entry[K, V], reason types.EvictReason) {
214 | if s.callback != nil {
215 | s.callback(e.key, e.value, reason)
216 | }
217 |
218 | if s.ghost.contains(e.key) {
219 | s.ghost.remove(e.key)
220 | }
221 |
222 | s.main.Remove(e.element)
223 | s.small.Remove(e.element)
224 | delete(s.items, e.key)
225 | }
226 |
227 | func (s *S3FIFO[K, V]) addToBucket(e *entry[K, V]) {
228 | if s.ttl == 0 {
229 | return
230 | }
231 | bucketId := (numberOfBuckets + s.nextCleanupBucket - 1) % numberOfBuckets
232 | e.bucketID = bucketId
233 | s.buckets[bucketId].entries[e.key] = e
234 | if s.buckets[bucketId].newestEntry.Before(e.expiredAt) {
235 | s.buckets[bucketId].newestEntry = e.expiredAt
236 | }
237 | }
238 |
239 | func (s *S3FIFO[K, V]) removeFromBucket(e *entry[K, V]) {
240 | if s.ttl == 0 {
241 | return
242 | }
243 | delete(s.buckets[e.bucketID].entries, e.key)
244 | }
245 |
246 | func (s *S3FIFO[K, V]) deleteExpired() {
247 | s.mu.Lock()
248 |
249 | bucketId := s.nextCleanupBucket
250 | s.nextCleanupBucket = (s.nextCleanupBucket + 1) % numberOfBuckets
251 | bucket := &s.buckets[bucketId]
252 | timeToExpire := time.Until(bucket.newestEntry)
253 | if timeToExpire > 0 {
254 | s.mu.Unlock()
255 | time.Sleep(timeToExpire)
256 | s.mu.Lock()
257 | }
258 |
259 | for _, e := range bucket.entries {
260 | s.removeEntry(e, types.EvictReasonExpired)
261 | }
262 |
263 | s.mu.Unlock()
264 | }
265 |
266 | func (s *S3FIFO[K, V]) evict() {
267 | // if size of the small queue is greater than 10% of the total cache size.
268 | // then, evict from the small queue
269 | if s.small.Len() > s.size/10 {
270 | s.evictFromSmall()
271 | return
272 | }
273 | s.evictFromMain()
274 | }
275 |
276 | func (s *S3FIFO[K, V]) evictFromSmall() {
277 | mainCacheSize := s.size / 10 * 9
278 |
279 | evicted := false
280 | for !evicted && s.small.Len() > 0 {
281 | key := s.small.Back().Value.(K)
282 | el, ok := s.items[key]
283 | if !ok {
284 | panic("s3fifo: entry not found in the cache")
285 | }
286 |
287 | if el.freq > 1 {
288 | // move the entry from the small queue to the main queue
289 | s.small.Remove(el.element)
290 | s.items[key].element = s.main.PushFront(el.key)
291 |
292 | if s.main.Len() > mainCacheSize {
293 | s.evictFromMain()
294 | }
295 | } else {
296 | s.removeEntry(el, types.EvictReasonEvicted)
297 | s.ghost.add(key)
298 | evicted = true
299 | delete(s.items, key)
300 | }
301 | }
302 | }
303 |
304 | func (s *S3FIFO[K, V]) evictFromMain() {
305 | evicted := false
306 | for !evicted && s.main.Len() > 0 {
307 | key := s.main.Back().Value.(K)
308 | el, ok := s.items[key]
309 | if !ok {
310 | panic("s3fifo: entry not found in the cache")
311 | }
312 |
313 | if el.freq > 0 {
314 | s.main.Remove(el.element)
315 | s.items[key].freq -= 1
316 | s.items[key].element = s.main.PushFront(el.key)
317 | } else {
318 | s.removeEntry(el, types.EvictReasonEvicted)
319 | evicted = true
320 | delete(s.items, key)
321 | }
322 | }
323 | }
324 |
--------------------------------------------------------------------------------
/s3fifo/s3fifo_test.go:
--------------------------------------------------------------------------------
1 | package s3fifo
2 |
3 | import (
4 | "sync"
5 | "testing"
6 | "time"
7 |
8 | "fortio.org/assert"
9 | "github.com/scalalang2/golang-fifo/types"
10 | )
11 |
12 | const noEvictionTTL = 0
13 |
14 | func TestSetAndGet(t *testing.T) {
15 | cache := New[string, string](10, noEvictionTTL)
16 | cache.Set("hello", "world")
17 |
18 | value, ok := cache.Get("hello")
19 | assert.True(t, ok)
20 | assert.Equal(t, "world", value)
21 | }
22 |
23 | func TestRemove(t *testing.T) {
24 | cache := New[int, int](10, noEvictionTTL)
25 | cache.Set(1, 10)
26 |
27 | val, ok := cache.Get(1)
28 | assert.True(t, ok)
29 | assert.Equal(t, 10, val)
30 |
31 | // After removing the key, it should not be found
32 | removed := cache.Remove(1)
33 | assert.True(t, removed)
34 |
35 | _, ok = cache.Get(1)
36 | assert.False(t, ok)
37 |
38 | // This should not panic
39 | removed = cache.Remove(-1)
40 | assert.False(t, removed)
41 | }
42 |
43 | func TestEvictOneHitWonders(t *testing.T) {
44 | cache := New[int, int](10, noEvictionTTL)
45 | oneHitWonders := []int{1, 2}
46 | popularObjects := []int{3, 4, 5, 6, 7, 8, 9, 10}
47 |
48 | // add objects to the cache
49 | for _, v := range oneHitWonders {
50 | cache.Set(v, v)
51 | }
52 | for _, v := range popularObjects {
53 | cache.Set(v, v)
54 | }
55 |
56 | // hit one-hit wonders only once
57 | for _, v := range oneHitWonders {
58 | _, ok := cache.Get(v)
59 | assert.True(t, ok)
60 | }
61 |
62 | // hit the popular objects
63 | for i := 0; i < 3; i++ {
64 | for _, v := range popularObjects {
65 | _, ok := cache.Get(v)
66 | assert.True(t, ok)
67 | }
68 | }
69 |
70 | // add more objects to the cache
71 | // this should evict the one hit wonders
72 | for i := 11; i < 20; i++ {
73 | cache.Set(i, i)
74 | }
75 |
76 | for _, v := range oneHitWonders {
77 | _, ok := cache.Get(v)
78 | assert.False(t, ok)
79 | }
80 |
81 | // popular objects should still be in the cache
82 | for _, v := range popularObjects {
83 | _, ok := cache.Get(v)
84 | assert.True(t, ok)
85 | }
86 | }
87 |
88 | func TestPeek(t *testing.T) {
89 | cache := New[int, int](5, noEvictionTTL)
90 | entries := []int{1, 2, 3, 4, 5}
91 |
92 | for _, v := range entries {
93 | cache.Set(v, v*10)
94 | }
95 |
96 | // peek each entry 10 times
97 | // this should not change the recent-ness of the entry.
98 | for i := 0; i < 10; i++ {
99 | for _, v := range entries {
100 | value, exist := cache.Peek(v)
101 | assert.True(t, exist)
102 | assert.Equal(t, v*10, value)
103 | }
104 | }
105 |
106 | // add more entries to the cache
107 | // this should evict the first 5 entries
108 | for i := 6; i <= 10; i++ {
109 | cache.Set(i, i*10)
110 | }
111 |
112 | // peek the first 5 entries
113 | // they should not exist in the cache
114 | for _, v := range entries {
115 | _, exist := cache.Peek(v)
116 | assert.False(t, exist)
117 | }
118 | }
119 |
120 | func TestContains(t *testing.T) {
121 | cache := New[int, int](5, noEvictionTTL)
122 | entries := []int{1, 2, 3, 4, 5}
123 |
124 | for _, v := range entries {
125 | cache.Set(v, v*10)
126 | }
127 |
128 | // check if each entry exists in the cache
129 | for _, v := range entries {
130 | assert.True(t, cache.Contains(v))
131 | }
132 |
133 | for i := 6; i <= 10; i++ {
134 | assert.False(t, cache.Contains(i))
135 | }
136 | }
137 |
138 | func TestLength(t *testing.T) {
139 | cache := New[string, string](10, noEvictionTTL)
140 |
141 | cache.Set("hello", "world")
142 | assert.Equal(t, 1, cache.Len())
143 |
144 | cache.Set("hello2", "world")
145 | cache.Set("hello", "changed")
146 | assert.Equal(t, 2, cache.Len())
147 |
148 | value, ok := cache.Get("hello")
149 | assert.True(t, ok)
150 | assert.Equal(t, "changed", value)
151 | }
152 |
153 | func TestClean(t *testing.T) {
154 | cache := New[int, int](10, noEvictionTTL)
155 | entries := []int{1, 2, 3, 4, 5}
156 |
157 | for _, v := range entries {
158 | cache.Set(v, v*10)
159 | }
160 | assert.Equal(t, 5, cache.Len())
161 | cache.Purge()
162 |
163 | // check if each entry exists in the cache
164 | for _, v := range entries {
165 | _, exist := cache.Peek(v)
166 | assert.False(t, exist)
167 | }
168 | assert.Equal(t, 0, cache.Len())
169 | }
170 |
171 | func TestTimeToLive(t *testing.T) {
172 | ttl := time.Second
173 | cache := New[int, int](10, ttl)
174 | numberOfEntries := 10
175 |
176 | for num := 1; num <= numberOfEntries; num++ {
177 | cache.Set(num, num)
178 | val, ok := cache.Get(num)
179 | assert.True(t, ok)
180 | assert.Equal(t, num, val)
181 | }
182 |
183 | time.Sleep(ttl * 2)
184 |
185 | // check all entries are evicted
186 | for num := 1; num <= numberOfEntries; num++ {
187 | _, ok := cache.Get(num)
188 | assert.False(t, ok)
189 | }
190 | }
191 |
192 | func TestEvictionCallback(t *testing.T) {
193 | cache := New[int, int](10, noEvictionTTL)
194 | evicted := make(map[int]int)
195 |
196 | cache.SetOnEvicted(func(key int, value int, _ types.EvictReason) {
197 | evicted[key] = value
198 | })
199 |
200 | // add objects to the cache
201 | for i := 1; i <= 10; i++ {
202 | cache.Set(i, i)
203 | }
204 |
205 | // add another object to the cache
206 | cache.Set(11, 11)
207 |
208 | // check the first object is evicted
209 | _, ok := cache.Get(1)
210 | assert.False(t, ok)
211 | assert.Equal(t, 1, evicted[1])
212 |
213 | cache.Close()
214 | }
215 |
216 | func TestEvictionCallbackWithTTL(t *testing.T) {
217 | var mu sync.Mutex
218 | cache := New[int, int](10, time.Second)
219 | evicted := make(map[int]int)
220 | cache.SetOnEvicted(func(key int, value int, _ types.EvictReason) {
221 | mu.Lock()
222 | evicted[key] = value
223 | mu.Unlock()
224 | })
225 |
226 | // add objects to the cache
227 | for i := 1; i <= 10; i++ {
228 | cache.Set(i, i)
229 | }
230 |
231 | timeout := time.After(5 * time.Second)
232 | ticker := time.NewTicker(100 * time.Millisecond)
233 | for {
234 | select {
235 | case <-timeout:
236 | t.Fatal("timeout")
237 | case <-ticker.C:
238 | mu.Lock()
239 | if len(evicted) == 10 {
240 | for i := 1; i <= 10; i++ {
241 | assert.Equal(t, i, evicted[i])
242 | }
243 | return
244 | }
245 | mu.Unlock()
246 | }
247 | }
248 | }
249 |
--------------------------------------------------------------------------------
/sieve/sieve.go:
--------------------------------------------------------------------------------
1 | package sieve
2 |
3 | import (
4 | "container/list"
5 | "context"
6 | "sync"
7 | "time"
8 |
9 | "github.com/scalalang2/golang-fifo/types"
10 | )
11 |
12 | // numberOfBuckets is the number of buckets to store the cache entries
13 | //
14 | // Notice: if this number exceeds 256, the type of nextCleanupBucket
15 | // in the Sieve struct should be changed to int16
16 | const numberOfBuckets = 100
17 |
18 | // entry holds the key and value of a cache entry.
19 | type entry[K comparable, V any] struct {
20 | key K
21 | value V
22 | visited bool
23 | element *list.Element
24 | expiredAt time.Time
25 | bucketID int8 // bucketID is an index which the entry is stored in the bucket
26 | }
27 |
28 | // bucket is a container holding entries to be expired
29 | type bucket[K comparable, V any] struct {
30 | entries map[K]*entry[K, V]
31 | newestEntry time.Time
32 | }
33 |
34 | type Sieve[K comparable, V any] struct {
35 | ctx context.Context
36 | cancel context.CancelFunc
37 | mu sync.Mutex
38 | size int
39 | items map[K]*entry[K, V]
40 | ll *list.List
41 | hand *list.Element
42 |
43 | buckets []bucket[K, V]
44 |
45 | // ttl is the time to live of the cache entry
46 | ttl time.Duration
47 |
48 | // nextCleanupBucket is an index of the next bucket to be cleaned up
49 | nextCleanupBucket int8
50 |
51 | // callback is the function that will be called when an entry is evicted from the cache
52 | callback types.OnEvictCallback[K, V]
53 | }
54 |
55 | var _ types.Cache[int, int] = (*Sieve[int, int])(nil)
56 |
57 | func New[K comparable, V any](size int, ttl time.Duration) *Sieve[K, V] {
58 | ctx, cancel := context.WithCancel(context.Background())
59 |
60 | if ttl <= 0 {
61 | ttl = 0
62 | }
63 |
64 | if size <= 0 {
65 | panic("sieve: size must be greater than 0")
66 | }
67 |
68 | cache := &Sieve[K, V]{
69 | ctx: ctx,
70 | cancel: cancel,
71 | size: size,
72 | items: make(map[K]*entry[K, V]),
73 | ll: list.New(),
74 | buckets: make([]bucket[K, V], numberOfBuckets),
75 | ttl: ttl,
76 | nextCleanupBucket: 0,
77 | }
78 |
79 | for i := 0; i < numberOfBuckets; i++ {
80 | cache.buckets[i].entries = make(map[K]*entry[K, V])
81 | }
82 |
83 | if ttl != 0 {
84 | go cache.cleanup(cache.ctx)
85 | }
86 |
87 | return cache
88 | }
89 |
90 | func (s *Sieve[K, V]) cleanup(ctx context.Context) {
91 | ticker := time.NewTicker(s.ttl / numberOfBuckets)
92 | defer ticker.Stop()
93 | for {
94 | select {
95 | case <-ctx.Done():
96 | return
97 | case <-ticker.C:
98 | s.deleteExpired()
99 | }
100 | }
101 | }
102 |
103 | func (s *Sieve[K, V]) Set(key K, value V) {
104 | s.mu.Lock()
105 | defer s.mu.Unlock()
106 |
107 | if e, ok := s.items[key]; ok {
108 | s.removeFromBucket(e) // remove from the bucket as the entry is updated
109 | e.value = value
110 | e.visited = true
111 | e.expiredAt = time.Now().Add(s.ttl)
112 | s.addToBucket(e)
113 | return
114 | }
115 |
116 | if s.ll.Len() >= s.size {
117 | s.evict()
118 | }
119 |
120 | e := &entry[K, V]{
121 | key: key,
122 | value: value,
123 | element: s.ll.PushFront(key),
124 | expiredAt: time.Now().Add(s.ttl),
125 | }
126 | s.items[key] = e
127 | s.addToBucket(e)
128 | }
129 |
130 | func (s *Sieve[K, V]) Get(key K) (value V, ok bool) {
131 | s.mu.Lock()
132 | defer s.mu.Unlock()
133 | if e, ok := s.items[key]; ok {
134 | e.visited = true
135 | return e.value, true
136 | }
137 |
138 | return
139 | }
140 |
141 | func (s *Sieve[K, V]) Remove(key K) (ok bool) {
142 | s.mu.Lock()
143 | defer s.mu.Unlock()
144 |
145 | if e, ok := s.items[key]; ok {
146 | // if the element to be removed is the hand,
147 | // then move the hand to the previous one.
148 | if e.element == s.hand {
149 | s.hand = s.hand.Prev()
150 | }
151 |
152 | s.removeEntry(e, types.EvictReasonRemoved)
153 | return true
154 | }
155 |
156 | return false
157 | }
158 |
159 | func (s *Sieve[K, V]) Contains(key K) (ok bool) {
160 | s.mu.Lock()
161 | defer s.mu.Unlock()
162 | _, ok = s.items[key]
163 | return
164 | }
165 |
166 | func (s *Sieve[K, V]) Peek(key K) (value V, ok bool) {
167 | s.mu.Lock()
168 | defer s.mu.Unlock()
169 |
170 | if e, ok := s.items[key]; ok {
171 | return e.value, true
172 | }
173 |
174 | return
175 | }
176 |
177 | func (s *Sieve[K, V]) SetOnEvicted(callback types.OnEvictCallback[K, V]) {
178 | s.mu.Lock()
179 | defer s.mu.Unlock()
180 |
181 | s.callback = callback
182 | }
183 |
184 | func (s *Sieve[K, V]) Len() int {
185 | s.mu.Lock()
186 | defer s.mu.Unlock()
187 |
188 | return s.ll.Len()
189 | }
190 |
191 | func (s *Sieve[K, V]) Purge() {
192 | s.mu.Lock()
193 | defer s.mu.Unlock()
194 |
195 | for _, e := range s.items {
196 | s.removeEntry(e, types.EvictReasonRemoved)
197 | }
198 |
199 | for i := range s.buckets {
200 | for k := range s.buckets[i].entries {
201 | delete(s.buckets[i].entries, k)
202 | }
203 | }
204 |
205 | // hand pointer must also be reset
206 | s.hand = nil
207 | s.nextCleanupBucket = 0
208 | s.ll.Init()
209 | }
210 |
211 | func (s *Sieve[K, V]) Close() {
212 | s.Purge()
213 | s.mu.Lock()
214 | s.cancel()
215 | s.mu.Unlock()
216 | }
217 |
218 | func (s *Sieve[K, V]) removeEntry(e *entry[K, V], reason types.EvictReason) {
219 | if s.callback != nil {
220 | s.callback(e.key, e.value, reason)
221 | }
222 |
223 | s.ll.Remove(e.element)
224 | s.removeFromBucket(e)
225 | delete(s.items, e.key)
226 | }
227 |
228 | func (s *Sieve[K, V]) evict() {
229 | o := s.hand
230 | // if o is nil, then assign it to the tail element in the list
231 | if o == nil {
232 | o = s.ll.Back()
233 | }
234 |
235 | el, ok := s.items[o.Value.(K)]
236 | if !ok {
237 | panic("sieve: evicting non-existent element")
238 | }
239 |
240 | for el.visited {
241 | el.visited = false
242 | o = o.Prev()
243 | if o == nil {
244 | o = s.ll.Back()
245 | }
246 |
247 | el, ok = s.items[o.Value.(K)]
248 | if !ok {
249 | panic("sieve: evicting non-existent element")
250 | }
251 | }
252 |
253 | s.hand = o.Prev()
254 | s.removeEntry(el, types.EvictReasonEvicted)
255 | }
256 |
257 | func (s *Sieve[K, V]) addToBucket(e *entry[K, V]) {
258 | if s.ttl == 0 {
259 | return
260 | }
261 | bucketId := (numberOfBuckets + int(s.nextCleanupBucket) - 1) % numberOfBuckets
262 | e.bucketID = int8(bucketId)
263 | s.buckets[bucketId].entries[e.key] = e
264 | if s.buckets[bucketId].newestEntry.Before(e.expiredAt) {
265 | s.buckets[bucketId].newestEntry = e.expiredAt
266 | }
267 | }
268 |
269 | func (s *Sieve[K, V]) removeFromBucket(e *entry[K, V]) {
270 | if s.ttl == 0 {
271 | return
272 | }
273 | delete(s.buckets[e.bucketID].entries, e.key)
274 | }
275 |
276 | func (s *Sieve[K, V]) deleteExpired() {
277 | s.mu.Lock()
278 |
279 | bucketId := s.nextCleanupBucket
280 | s.nextCleanupBucket = (s.nextCleanupBucket + 1) % numberOfBuckets
281 | bucket := &s.buckets[bucketId]
282 | timeToExpire := time.Until(bucket.newestEntry)
283 | if timeToExpire > 0 {
284 | s.mu.Unlock()
285 | time.Sleep(timeToExpire)
286 | s.mu.Lock()
287 | }
288 |
289 | for _, e := range bucket.entries {
290 | s.removeEntry(e, types.EvictReasonExpired)
291 | }
292 |
293 | s.mu.Unlock()
294 | }
295 |
--------------------------------------------------------------------------------
/sieve/sieve_test.go:
--------------------------------------------------------------------------------
1 | package sieve
2 |
3 | import (
4 | "sync"
5 | "testing"
6 | "time"
7 |
8 | "fortio.org/assert"
9 | "github.com/scalalang2/golang-fifo/types"
10 | )
11 |
12 | const noEvictionTTL = 0
13 |
14 | func TestGetAndSet(t *testing.T) {
15 | items := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
16 | cache := New[int, int](10, noEvictionTTL)
17 |
18 | for _, v := range items {
19 | cache.Set(v, v*10)
20 | }
21 |
22 | for _, v := range items {
23 | val, ok := cache.Get(v)
24 | assert.True(t, ok)
25 | assert.Equal(t, v*10, val)
26 | }
27 |
28 | cache.Close()
29 | }
30 |
31 | func TestRemove(t *testing.T) {
32 | cache := New[int, int](10, noEvictionTTL)
33 | cache.Set(1, 10)
34 |
35 | val, ok := cache.Get(1)
36 | assert.True(t, ok)
37 | assert.Equal(t, 10, val)
38 |
39 | // After removing the key, it should not be found
40 | removed := cache.Remove(1)
41 | assert.True(t, removed)
42 |
43 | _, ok = cache.Get(1)
44 | assert.False(t, ok)
45 |
46 | // This should not panic
47 | removed = cache.Remove(-1)
48 | assert.False(t, removed)
49 |
50 | cache.Close()
51 | }
52 |
53 | func TestSievePolicy(t *testing.T) {
54 | cache := New[int, int](10, noEvictionTTL)
55 | oneHitWonders := []int{1, 2, 3, 4, 5}
56 | popularObjects := []int{6, 7, 8, 9, 10}
57 |
58 | // add objects to the cache
59 | for _, v := range oneHitWonders {
60 | cache.Set(v, v)
61 | }
62 | for _, v := range popularObjects {
63 | cache.Set(v, v)
64 | }
65 |
66 | // hit popular objects
67 | for _, v := range popularObjects {
68 | _, ok := cache.Get(v)
69 | assert.True(t, ok)
70 | }
71 |
72 | // add another objects to the cache
73 | for _, v := range oneHitWonders {
74 | cache.Set(v*10, v*10)
75 | }
76 |
77 | // check popular objects are not evicted
78 | for _, v := range popularObjects {
79 | _, ok := cache.Get(v)
80 | assert.True(t, ok)
81 | }
82 |
83 | cache.Close()
84 | }
85 |
86 | func TestContains(t *testing.T) {
87 | cache := New[string, string](10, noEvictionTTL)
88 | assert.False(t, cache.Contains("hello"))
89 |
90 | cache.Set("hello", "world")
91 | assert.True(t, cache.Contains("hello"))
92 |
93 | cache.Close()
94 | }
95 |
96 | func TestLen(t *testing.T) {
97 | cache := New[int, int](10, noEvictionTTL)
98 | assert.Equal(t, 0, cache.Len())
99 |
100 | cache.Set(1, 1)
101 | assert.Equal(t, 1, cache.Len())
102 |
103 | // duplicated keys only update the recent-ness of the key and value
104 | cache.Set(1, 1)
105 | assert.Equal(t, 1, cache.Len())
106 |
107 | cache.Set(2, 2)
108 | assert.Equal(t, 2, cache.Len())
109 |
110 | cache.Close()
111 | }
112 |
113 | func TestPurge(t *testing.T) {
114 | cache := New[int, int](10, noEvictionTTL)
115 | cache.Set(1, 1)
116 | cache.Set(2, 2)
117 | assert.Equal(t, 2, cache.Len())
118 |
119 | cache.Purge()
120 | assert.Equal(t, 0, cache.Len())
121 |
122 | cache.Close()
123 | }
124 |
125 | func TestTimeToLive(t *testing.T) {
126 | ttl := time.Second
127 | cache := New[int, int](10, ttl)
128 | numberOfEntries := 10
129 |
130 | for num := 1; num <= numberOfEntries; num++ {
131 | cache.Set(num, num)
132 | val, ok := cache.Get(num)
133 | assert.True(t, ok)
134 | assert.Equal(t, num, val)
135 | }
136 |
137 | time.Sleep(ttl * 2)
138 |
139 | // check all entries are evicted
140 | for num := 1; num <= numberOfEntries; num++ {
141 | _, ok := cache.Get(num)
142 | assert.False(t, ok)
143 | }
144 | }
145 |
146 | func TestEvictionCallback(t *testing.T) {
147 | cache := New[int, int](10, noEvictionTTL)
148 | evicted := make(map[int]int)
149 |
150 | cache.SetOnEvicted(func(key int, value int, _ types.EvictReason) {
151 | evicted[key] = value
152 | })
153 |
154 | // add objects to the cache
155 | for i := 1; i <= 10; i++ {
156 | cache.Set(i, i)
157 | }
158 |
159 | // add another object to the cache
160 | cache.Set(11, 11)
161 |
162 | // check the first object is evicted
163 | _, ok := cache.Get(1)
164 | assert.False(t, ok)
165 | assert.Equal(t, 1, evicted[1])
166 |
167 | cache.Close()
168 | }
169 |
170 | func TestEvictionCallbackWithTTL(t *testing.T) {
171 | var mu sync.Mutex
172 | cache := New[int, int](10, time.Second)
173 | evicted := make(map[int]int)
174 | cache.SetOnEvicted(func(key int, value int, _ types.EvictReason) {
175 | mu.Lock()
176 | evicted[key] = value
177 | mu.Unlock()
178 | })
179 |
180 | // add objects to the cache
181 | for i := 1; i <= 10; i++ {
182 | cache.Set(i, i)
183 | }
184 |
185 | timeout := time.After(5 * time.Second)
186 | ticker := time.NewTicker(100 * time.Millisecond)
187 | for {
188 | select {
189 | case <-timeout:
190 | t.Fatal("timeout")
191 | case <-ticker.C:
192 | mu.Lock()
193 | if len(evicted) == 10 {
194 | for i := 1; i <= 10; i++ {
195 | assert.Equal(t, i, evicted[i])
196 | }
197 | return
198 | }
199 | mu.Unlock()
200 | }
201 | }
202 | }
203 |
204 | func TestLargerWorkloadsThanCacheSize(t *testing.T) {
205 | type value struct {
206 | bytes []byte
207 | }
208 |
209 | cache := New[int32, value](512, time.Millisecond)
210 | workload := int32(10240)
211 | for i := int32(0); i < workload; i++ {
212 | val := value{
213 | bytes: make([]byte, 10),
214 | }
215 | cache.Set(i, val)
216 |
217 | v, ok := cache.Get(i)
218 | assert.True(t, ok)
219 | assert.Equal(t, v, val)
220 | }
221 | }
222 |
--------------------------------------------------------------------------------
/types/types.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | // EvictReason is the reason for an entry to be evicted from the cache.
4 | // It is used in the [OnEvictCallback] function.
5 | type EvictReason int
6 |
7 | const (
8 | // EvictReasonExpired is used when an item is removed because its TTL has expired.
9 | EvictReasonExpired = iota
10 | // EvictReasonEvicted is used when an item is removed because the cache size limit was exceeded.
11 | EvictReasonEvicted
12 | // EvictReasonRemoved is used when an item is explicitly deleted.
13 | EvictReasonRemoved
14 | )
15 |
16 | type OnEvictCallback[K comparable, V any] func(key K, value V, reason EvictReason)
17 |
18 | // Cache is the interface for a cache.
19 | type Cache[K comparable, V any] interface {
20 | // Set sets the value for the given key on cache.
21 | Set(key K, value V)
22 |
23 | // Get gets the value for the given key from cache.
24 | Get(key K) (value V, ok bool)
25 |
26 | // Remove removes the provided key from the cache.
27 | Remove(key K) (ok bool)
28 |
29 | // Contains check if a key exists in cache without updating the recent-ness
30 | Contains(key K) (ok bool)
31 |
32 | // Peek returns key's value without updating the recent-ness.
33 | Peek(key K) (value V, ok bool)
34 |
35 | // SetOnEvicted sets the callback function that will be called when an entry is evicted from the cache.
36 | SetOnEvicted(callback OnEvictCallback[K, V])
37 |
38 | // Len returns the number of entries in the cache.
39 | Len() int
40 |
41 | // Purge clears all cache entries
42 | Purge()
43 |
44 | // Close closes the cache and releases any resources associated with it.
45 | Close()
46 | }
47 |
--------------------------------------------------------------------------------