Skip to content

Commit dcc99f6

Browse files
committed
Replace LRU cache with a simpler map that expires unused items.
1 parent b74f5a0 commit dcc99f6

File tree

5 files changed

+200
-240
lines changed

5 files changed

+200
-240
lines changed

cmd/outline-ss-server/expiring_map.go

+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright 2024 The Outline Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"sync"
19+
"sync/atomic"
20+
"time"
21+
)
22+
23+
// ExpiringMap is a thread-safe, generic map that automatically removes
24+
// key-value pairs after a specified duration of inactivity.
25+
// It employs reference counting to ensure safe concurrent access and prevent
26+
// premature deletion during cleanup.
27+
type ExpiringMap[K comparable, V any] struct {
28+
data map[K]*item[V]
29+
mu sync.RWMutex
30+
expiryTime time.Duration
31+
done chan struct{}
32+
}
33+
34+
type item[V any] struct {
35+
value V
36+
lastAccess time.Time
37+
refCount int32
38+
}
39+
40+
func NewExpiringMap[K comparable, V any](expiryTime time.Duration) *ExpiringMap[K, V] {
41+
em := &ExpiringMap[K, V]{
42+
data: make(map[K]*item[V]),
43+
expiryTime: expiryTime,
44+
done: make(chan struct{}),
45+
}
46+
go em.cleanupLoop()
47+
return em
48+
}
49+
50+
func (em *ExpiringMap[K, V]) Set(key K, value V) {
51+
em.mu.Lock()
52+
defer em.mu.Unlock()
53+
54+
em.data[key] = &item[V]{
55+
value: value,
56+
lastAccess: time.Now(),
57+
refCount: 0,
58+
}
59+
}
60+
61+
func (em *ExpiringMap[K, V]) Get(key K) (V, bool) {
62+
em.mu.RLock()
63+
item, ok := em.data[key]
64+
if !ok {
65+
em.mu.RUnlock()
66+
var zeroValue V
67+
return zeroValue, false
68+
}
69+
70+
atomic.AddInt32(&item.refCount, 1)
71+
em.mu.RUnlock()
72+
73+
em.mu.Lock()
74+
defer em.mu.Unlock()
75+
76+
atomic.AddInt32(&item.refCount, -1)
77+
78+
item.lastAccess = time.Now()
79+
return item.value, true
80+
}
81+
82+
func (em *ExpiringMap[K, V]) cleanup() {
83+
em.mu.Lock()
84+
defer em.mu.Unlock()
85+
86+
for key, item := range em.data {
87+
if time.Since(item.lastAccess) > em.expiryTime && atomic.LoadInt32(&item.refCount) == 0 {
88+
delete(em.data, key)
89+
}
90+
}
91+
}
92+
93+
func (em *ExpiringMap[K, V]) cleanupLoop() {
94+
ticker := time.NewTicker(em.expiryTime / 2)
95+
defer ticker.Stop()
96+
97+
for {
98+
select {
99+
case <-ticker.C:
100+
em.cleanup()
101+
case <-em.done:
102+
return
103+
}
104+
}
105+
}
106+
107+
func (em *ExpiringMap[K, V]) StopCleanup() {
108+
close(em.done)
109+
}
+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright 2024 The Outline Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"fmt"
19+
"sync"
20+
"testing"
21+
"time"
22+
23+
"github.com/stretchr/testify/require"
24+
)
25+
26+
func TestExpiringMap(t *testing.T) {
27+
t.Run("BasicSetGet", func(t *testing.T) {
28+
em := NewExpiringMap[string, int](2 * time.Second)
29+
em.Set("key1", 10)
30+
val, ok := em.Get("key1")
31+
require.True(t, ok)
32+
require.Equal(t, 10, val)
33+
34+
time.Sleep(3 * time.Second)
35+
36+
_, ok = em.Get("key1")
37+
require.False(t, ok)
38+
})
39+
40+
t.Run("Expiration", func(t *testing.T) {
41+
em := NewExpiringMap[string, int](2 * time.Second)
42+
em.Set("a", 1)
43+
44+
time.Sleep(3 * time.Second)
45+
46+
_, ok := em.Get("a")
47+
require.False(t, ok, "Expected `a` to have been evicted")
48+
em.StopCleanup()
49+
})
50+
51+
t.Run("Concurrency", func(t *testing.T) {
52+
em := NewExpiringMap[int, string](2 * time.Second)
53+
var wg sync.WaitGroup
54+
for i := 0; i < 100; i++ {
55+
wg.Add(1)
56+
go func(i int) {
57+
defer wg.Done()
58+
em.Set(i, fmt.Sprintf("value%d", i))
59+
60+
time.Sleep(time.Duration(i) * time.Millisecond)
61+
62+
val, ok := em.Get(i)
63+
require.True(t, ok)
64+
require.Equal(t, fmt.Sprintf("value%d", i), val)
65+
}(i)
66+
}
67+
wg.Wait()
68+
})
69+
}
70+
71+
func BenchmarkExpiringMap(b *testing.B) {
72+
b.Run("Set", func(b *testing.B) {
73+
em := NewExpiringMap[int64, int64](10 * time.Second)
74+
for i := 0; i < b.N; i++ {
75+
em.Set(int64(i), int64(i))
76+
}
77+
})
78+
79+
b.Run("Get", func(b *testing.B) {
80+
em := NewExpiringMap[int64, int64](10 * time.Second)
81+
for i := 0; i < b.N; i++ {
82+
em.Set(int64(i), int64(i))
83+
}
84+
b.ResetTimer()
85+
for i := 0; i < b.N; i++ {
86+
em.Get(int64(i))
87+
}
88+
})
89+
}

cmd/outline-ss-server/lru_cache.go

-132
This file was deleted.

0 commit comments

Comments
 (0)