From c544bb153ce407cc78e8363fe733865249e0ac4e Mon Sep 17 00:00:00 2001 From: Ukasha Zia Date: Sun, 16 Feb 2025 19:31:32 +0500 Subject: [PATCH 1/2] Create go.yml --- .github/workflows/go.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/go.yml diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..5c871a4 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,28 @@ +# This workflow will build a golang project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go + +name: Go + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.23.0' + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -race -v ./... From afb11584799075d3b58ed80f725b9b479563a5f9 Mon Sep 17 00:00:00 2001 From: Ukasha Zia Date: Sun, 16 Feb 2025 19:37:38 +0500 Subject: [PATCH 2/2] Merge pull request #1 from ukashazia/ttl TTL mem-cache --- go.mod | 11 ++++++ go.sum | 12 ++++++ ttl/ttl.go | 69 ++++++++++++++++++++++++++++------- ttl/ttl_test.go | 97 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 go.sum create mode 100644 ttl/ttl_test.go diff --git a/go.mod b/go.mod index 513877c..0849225 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,14 @@ module github.com/ukashazia/memcache go 1.23.0 + +require ( + github.com/google/uuid v1.6.0 + github.com/stretchr/testify v1.10.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..14c872b --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ttl/ttl.go b/ttl/ttl.go index 8def747..c2f5e3d 100644 --- a/ttl/ttl.go +++ b/ttl/ttl.go @@ -4,6 +4,8 @@ import ( "context" "sync" "time" + + "github.com/google/uuid" ) type TTL struct { @@ -23,9 +25,11 @@ type Item struct { } type shard struct { - id uint64 - data map[string]*Item - mutex sync.RWMutex + id uint64 + uuid uuid.UUID + data map[string]*Item + termFunc context.CancelFunc + mutex sync.RWMutex } type shards map[uint64]*shard @@ -39,7 +43,7 @@ type shardLookupTable struct { func (ttl *TTL) Init() error { newShard := shard{} - ttl.shardLookupTable = shardLookupTable{shards: shards{}} + ttl.shardLookupTable = shardLookupTable{shards: make(shards)} ttl.newShard(&newShard) @@ -47,8 +51,11 @@ func (ttl *TTL) Init() error { } func (ttl *TTL) Put(item *Item) (uint64, error) { + + ttl.shardLookupTable.mutex.RLock() shardId := ttl.shardLookupTable.currentShardId currentShard := ttl.shardLookupTable.shards[shardId] + ttl.shardLookupTable.mutex.RUnlock() if item.TTL.Nanoseconds() > 0 { item.expirationTime = time.Now().Add(item.TTL) @@ -56,13 +63,14 @@ func (ttl *TTL) Put(item *Item) (uint64, error) { item.expirationTime = time.Now().Add(ttl.DefaultTTL) } + currentShard.mutex.Lock() if uint64(len(currentShard.data)) < ttl.ShardSize { - currentShard.mutex.Lock() defer currentShard.mutex.Unlock() currentShard.data[item.Key] = item } else { + currentShard.mutex.Unlock() newShard := shard{} ttl.newShard(&newShard) @@ -78,7 +86,17 @@ func (ttl *TTL) Put(item *Item) (uint64, error) { } func (ttl *TTL) Get(key string, shardId uint64) any { - data, exists := ttl.shardLookupTable.shards[shardId].data[key] + ttl.shardLookupTable.mutex.RLock() + shard, exists := ttl.shardLookupTable.shards[shardId] + ttl.shardLookupTable.mutex.RUnlock() + + if !exists { + return nil + } + + shard.mutex.RLock() + data, exists := shard.data[key] + shard.mutex.RUnlock() if !exists { return nil @@ -95,7 +113,12 @@ func (ttl *TTL) Get(key string, shardId uint64) any { } func (ttl *TTL) Delete(key string, shardId uint64) { - shard := ttl.shardLookupTable.shards[shardId] + shard, exists := ttl.shardLookupTable.shards[shardId] + + if !exists { + return + } + shard.mutex.Lock() defer shard.mutex.Unlock() @@ -108,13 +131,16 @@ func (ttl *TTL) newShard(shard *shard) { defer ttl.shardLookupTable.mutex.Unlock() newShardId := ttl.shardLookupTable.currentShardId + 1 + ctx, cancel := context.WithCancel(context.Background()) + shard.id = newShardId shard.data = make(map[string]*Item) + shard.uuid = uuid.New() + shard.termFunc = cancel ttl.shardLookupTable.shards[newShardId] = shard ttl.shardLookupTable.currentShardId = newShardId - ctx := context.Background() go shard.cleanup(ctx, ttl) } @@ -129,6 +155,7 @@ func (shard *shard) cleanup(ctx context.Context, ttl *TTL) { case <-ticker.C: var expiredKeys []string + shard.mutex.Lock() for k, v := range shard.data { if v.expirationTime.Before(time.Now()) { expiredKeys = append(expiredKeys, k) @@ -136,17 +163,33 @@ func (shard *shard) cleanup(ctx context.Context, ttl *TTL) { } if len(expiredKeys) > 0 { - shard.mutex.Lock() for _, k := range expiredKeys { delete(shard.data, k) } - if len(shard.data) == 0 { - delete(ttl.shardLookupTable.shards, shard.id) // idk if deleting the shard which is references in its own cleanup goroutine would work + } - return - } + shardEmpty := len(shard.data) == 0 + + if shardEmpty { shard.mutex.Unlock() + ttl.terminateShard(shard) + return } + shard.mutex.Unlock() } } } + +func (ttl *TTL) terminateShard(shard *shard) { + + ttl.shardLookupTable.mutex.Lock() + defer ttl.shardLookupTable.mutex.Unlock() + + shard.mutex.Lock() + defer shard.mutex.Unlock() + + if _, exists := ttl.shardLookupTable.shards[shard.id]; exists { + shard.termFunc() + delete(ttl.shardLookupTable.shards, shard.id) + } +} diff --git a/ttl/ttl_test.go b/ttl/ttl_test.go new file mode 100644 index 0000000..a5e4ec5 --- /dev/null +++ b/ttl/ttl_test.go @@ -0,0 +1,97 @@ +package ttl_test + +import ( + "strconv" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/ukashazia/memcache/ttl" +) + +func TestPutAndGet(t *testing.T) { + cache := &ttl.TTL{ + DefaultTTL: 1 * time.Second, + ShardSize: 2, + CleanupInterval: 500 * time.Millisecond, + } + cache.Init() + + item := &ttl.Item{Key: "test", Value: "value", TTL: 1 * time.Second} + shardID, err := cache.Put(item) + assert.Nil(t, err) + + val := cache.Get("test", shardID) + assert.Equal(t, "value", val) +} + +func TestExpiration(t *testing.T) { + cache := &ttl.TTL{ + DefaultTTL: 500 * time.Millisecond, + ShardSize: 2, + CleanupInterval: 250 * time.Millisecond, + } + cache.Init() + + item := &ttl.Item{Key: "temp", Value: "to expire", TTL: 500 * time.Millisecond} + shardID, _ := cache.Put(item) + + time.Sleep(600 * time.Millisecond) + val := cache.Get("temp", shardID) + assert.Nil(t, val) +} + +func TestShardCreation(t *testing.T) { + cache := &ttl.TTL{ + DefaultTTL: 1 * time.Second, + ShardSize: 1, + CleanupInterval: 500 * time.Millisecond, + } + cache.Init() + + item1 := &ttl.Item{Key: "item1", Value: "data1", TTL: 1 * time.Second} + shard1, _ := cache.Put(item1) + item2 := &ttl.Item{Key: "item2", Value: "data2", TTL: 1 * time.Second} + shard2, _ := cache.Put(item2) + + assert.NotEqual(t, shard1, shard2) +} + +func TestConcurrentAccess(t *testing.T) { + cache := &ttl.TTL{ + DefaultTTL: 1 * time.Second, + ShardSize: 10, + CleanupInterval: 500 * time.Millisecond, + } + cache.Init() + + var wg sync.WaitGroup + n := 100 + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + item := &ttl.Item{Key: strconv.Itoa(i), Value: i, TTL: 1 * time.Second} + _, _ = cache.Put(item) + }(i) + } + + wg.Wait() + assert.NotEmpty(t, cache.Get("1", 1)) +} + +func TestDelete(t *testing.T) { + cache := &ttl.TTL{ + DefaultTTL: 1 * time.Second, + ShardSize: 2, + CleanupInterval: 500 * time.Millisecond, + } + cache.Init() + + item := &ttl.Item{Key: "delete_me", Value: "gone", TTL: 1 * time.Second} + shardID, _ := cache.Put(item) + cache.Delete("delete_me", shardID) + + assert.Nil(t, cache.Get("delete_me", shardID)) +}