diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..a82073e --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,17 @@ +name: test + +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + - uses: golangci/golangci-lint-action@v6 + - name: go list + run: go list -json -m all > go.list + - uses: sonatype-nexus-community/nancy-github-action@main + - run: go test -race ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a75a94 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# ExpiryMap + +This Go package provides a map that automatically removes entries after a given expiry delay. + +## Features + +* The map key can be any comparable type +* The map value can be any type +* The map is safe for concurrent use +* The expiry delay is specified as a `time.Duration` value + +## Methods + +* NewExpiryMap - creates a new ExpiryMap +* Get, Set, Delete - standard map operations +* Len - returns the number of entries in the map +* Iterate - iterates over all entries in the map +* Clear - removes all entries from the map +* Stop - stops the background goroutine that removes expired entries + +## Example + +See [example/main.go](./example/main.go) diff --git a/expiry-map.go b/expiry-map.go new file mode 100644 index 0000000..a36d1e1 --- /dev/null +++ b/expiry-map.go @@ -0,0 +1,136 @@ +package expirymap + +import ( + "iter" + "sync" + "time" +) + +// ExpiryMap is a map of K key and V values with a builtin garbage cleaner that automatically +// deletes entries after a given expiry delay. +type ExpiryMap[K comparable, V any] struct { + storedMap map[K]*Content[V] + expiryDelay time.Duration + gargabeCleanInterval time.Duration + mutex sync.Mutex + stop chan bool +} + +// Content is the value stored for a given key in the ExpiryMap. +type Content[V any] struct { + Data V + + lastUpdated time.Time +} + +// New returns a new ExpiryMap. +// It also starts a goroutine that periodically cleans up expired entries +// according to the expiryDelay every gargabeCleanInterval. +func New[K comparable, V any](expiryDelay, gargabeCleanInterval time.Duration) *ExpiryMap[K, V] { + s := &ExpiryMap[K, V]{ + storedMap: make(map[K]*Content[V]), + expiryDelay: expiryDelay, + gargabeCleanInterval: gargabeCleanInterval, + stop: make(chan bool), + } + + s.start() + + return s +} + +// Get returns the value for a given key. +func (s *ExpiryMap[K, V]) Get(key K) V { + s.mutex.Lock() + defer s.mutex.Unlock() + + content, found := s.storedMap[key] + if !found { + content = &Content[V]{} + } + + return content.Data +} + +// Set sets the value for a given key and reset its expiry time. +func (s *ExpiryMap[K, V]) Set(key K, data V) { + s.mutex.Lock() + defer s.mutex.Unlock() + + content := &Content[V]{} + content.lastUpdated = time.Now() + content.Data = data + s.storedMap[key] = content +} + +// Delete deletes the value for a given key. +func (s *ExpiryMap[K, V]) Delete(key K) { + s.mutex.Lock() + defer s.mutex.Unlock() + + delete(s.storedMap, key) +} + +// Len returns the number of stored entries. +func (s *ExpiryMap[K, V]) Len() int { + s.mutex.Lock() + defer s.mutex.Unlock() + + return len(s.storedMap) +} + +// Iterate returns an iterator to loop over the stored entries. +func (s *ExpiryMap[K, V]) Iterate() iter.Seq2[K, V] { + return func(next func(K, V) bool) { + s.mutex.Lock() + defer s.mutex.Unlock() + + for k, v := range s.storedMap { + if !next(k, v.Data) { + return + } + } + } +} + +// Clear deletes all stored entries. +func (s *ExpiryMap[K, V]) Clear() { + s.mutex.Lock() + defer s.mutex.Unlock() + + clear(s.storedMap) +} + +// Stop stops the garbage cleaner goroutine. +func (s *ExpiryMap[K, V]) Stop() { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.stop <- true +} + +func (s *ExpiryMap[K, V]) start() { + go func() { + for { + select { + case <-s.stop: + return + case <-time.Tick(s.gargabeCleanInterval): + s.gargabeClean() + } + } + }() +} + +func (s *ExpiryMap[K, V]) gargabeClean() { + s.mutex.Lock() + defer s.mutex.Unlock() + + expiredTime := time.Now().Add(-s.expiryDelay) + + for key, u := range s.storedMap { + if u.lastUpdated.Before(expiredTime) { + delete(s.storedMap, key) + } + } +} diff --git a/expiry-map_test.go b/expiry-map_test.go new file mode 100644 index 0000000..3161bc4 --- /dev/null +++ b/expiry-map_test.go @@ -0,0 +1,136 @@ +package expirymap + +import ( + "reflect" + "testing" + "time" +) + +func TestNew(t *testing.T) { + m := New[int, bool](time.Minute, time.Minute) + if m == nil { + t.Fatalf("expected ExpiryMap, got nil") + } +} + +func TestGetUnset(t *testing.T) { + key := 1 + + m := New[int, string](time.Minute, time.Minute) + + if v := m.Get(key); !reflect.DeepEqual(v, "") { + t.Fatalf("expected %v, got %v", "", v) + } +} + +func TestSetGet(t *testing.T) { + key := 1 + value := "alice" + + m := New[int, string](time.Minute, time.Minute) + m.Set(key, value) + + if v := m.Get(key); !reflect.DeepEqual(v, value) { + t.Fatalf("expected %v, got %v", value, v) + } +} + +func TestSetDeleteGet(t *testing.T) { + key := 2 + value := "bob" + + m := New[int, string](time.Minute, time.Minute) + m.Set(key, value) + m.Delete(key) + + if v := m.Get(key); !reflect.DeepEqual(v, "") { + t.Fatalf("expected %v, got %v", "", v) + } +} + +func TestDeleteUnset(t *testing.T) { + key := 3 + + m := New[int, string](time.Minute, time.Minute) + m.Delete(key) +} + +func TestLen(t *testing.T) { + key := 4 + value := "charlie" + + m := New[int, string](time.Minute, time.Minute) + + if l := m.Len(); l != 0 { + t.Fatalf("expected %v, got %v", 0, l) + } + + m.Set(key, value) + + if l := m.Len(); l != 1 { + t.Fatalf("expected %v, got %v", 1, l) + } +} + +func TestIterate(t *testing.T) { + key := 5 + value := "dave" + + m := New[int, string](time.Minute, time.Minute) + m.Set(key, value) + + var count int + var k int + var v string + for k, v = range m.Iterate() { + count++ + } + + if count != 1 { + t.Fatalf("expected %v, got %v", 1, count) + } + + if k != key { + t.Fatalf("expected %v, got %v", key, k) + } + + if v != value { + t.Fatalf("expected %v, got %v", value, v) + } +} + +func TestClear(t *testing.T) { + key := 6 + value := "eve" + + m := New[int, string](time.Minute, time.Minute) + m.Set(key, value) + m.Clear() + + if l := m.Len(); l != 0 { + t.Fatalf("expected %v, got %v", 0, l) + } +} + +func TestStop(t *testing.T) { + m := New[int, string](time.Minute, time.Minute) + m.Stop() +} + +func TestGargabeClean(t *testing.T) { + key := 7 + value := "frank" + + m := New[int, string](time.Nanosecond, time.Millisecond) + m.Set(key, value) + + if v := m.Get(key); !reflect.DeepEqual(v, value) { + t.Fatalf("expected %v, got %v", value, v) + } + + time.Sleep(time.Millisecond * 2) + + if v := m.Get(key); v != "" { + t.Fatalf("expected nil, got %v", v) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..64e3ba0 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/TheoBrigitte/expirymap + +go 1.23 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29