├── .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 | [![Go Reference](https://pkg.go.dev/badge/github.com/scalalang2/golang-fifo.svg)](https://pkg.go.dev/github.com/scalalang2/golang-fifo) 2 | [![Go Report Card](https://goreportcard.com/badge/github.com/scalalang2/golang-fifo)](https://goreportcard.com/report/github.com/scalalang2/golang-fifo) 3 | ![MIT License](https://img.shields.io/badge/license-MIT-_red.svg) 4 | [![Coverage Status](https://coveralls.io/repos/github/scalalang2/golang-fifo/badge.svg?branch=main)](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 | ![zipflaw_discovered_by_realworld](./docs/zipf_law_discovered_by_realworld_traces.png) 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 | ![s3-fifo-is-powerful-algorithm](./docs/graphs_shows_s3_fifo_is_powerful.png) 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 | --------------------------------------------------------------------------------