Skip to content

Refactor MemoryCache to use ExpiresAt (less goroutines)#10

Merged
nDmitry merged 1 commit into
mainfrom
mem-cache-expires-at
Jan 13, 2026
Merged

Refactor MemoryCache to use ExpiresAt (less goroutines)#10
nDmitry merged 1 commit into
mainfrom
mem-cache-expires-at

Conversation

@nDmitry
Copy link
Copy Markdown
Owner

@nDmitry nDmitry commented Jan 13, 2026

Summary by CodeRabbit

  • New Features

    • TTL support for cache entries — items expire after the configured duration; TTL=0 items are not stored.
  • Bug Fixes

    • Expired entries are evicted on access and removed by periodic cleanup.
    • Cache shutdown now stops background cleanup to ensure clean close.
  • Tests

    • Tests refactored to table-driven format and expanded to cover TTL behavior and in-access eviction.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 13, 2026

Walkthrough

MemoryCache now stores values as cacheItem with expiration timestamps, skips caching when TTL is zero, evicts expired entries on access, and runs a background cleanup goroutine (signaled via a stop channel) that periodically removes expired items.

Changes

Cohort / File(s) Summary
Cache implementation
internal/cache/memory.go
Added cacheItem{value []byte, expiresAt time.Time}; changed MemoryCache.cache to map[string]cacheItem and added stop chan struct{}. NewMemoryClient initializes state and starts a 1-minute cleanup goroutine. Set stores expiresAt = now + ttl and skips caching when ttl==0. Get checks expiry, evicts expired items and returns ErrCacheMiss on miss. Added cleanup(now time.Time), updated snapshot() to map[string]cacheItem, and Close() signals stop.
Tests
internal/cache/memory_test.go
Converted to table-driven tests; expectations now validate snapshot length and TTL behavior. Tests cover zero-TTL (no caching) and in-Get eviction of expired entries. Uses synctest.Test harness and defers c.Close(); assertions updated for map[string]cacheItem.

Sequence Diagram

sequenceDiagram
    participant Client
    participant MemoryCache
    participant Clock as time.Now()
    participant Cleanup as CleanupGoroutine

    Client->>MemoryCache: Set(key, value, ttl)
    MemoryCache->>Clock: now
    MemoryCache->>MemoryCache: store key -> cacheItem{value, expiresAt}

    Client->>MemoryCache: Get(key)
    MemoryCache->>Clock: now
    alt not expired
        MemoryCache-->>Client: return value
    else expired
        MemoryCache->>MemoryCache: delete key
        MemoryCache-->>Client: return ErrCacheMiss
    end

    loop every 1 minute
        Cleanup->>Clock: now
        Cleanup->>MemoryCache: cleanup(now)
        MemoryCache->>MemoryCache: remove expired items
    end
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐇 I hopped into bytes and stamped each with time,

TTLs keep me tidy, I nibble stale rind,
Zero means "no stash" — I politely decline,
A minute-tick broom keeps the cache fields fine,
Little rabbit cleaner, coding in rhyme.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: refactoring MemoryCache to store expiration times with items instead of using separate goroutines for each item, reducing goroutine overhead.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In @internal/cache/memory.go:
- Around line 89-99: The cleanup method on MemoryCache currently iterates over
c.cache without holding c.mu, causing a data race with concurrent Set calls;
modify cleanup to acquire the write lock (c.mu.Lock()) before iterating over
c.cache and keep it held for the duration of the loop, performing deletes while
locked, then release the lock (c.mu.Unlock()) after finishing to ensure safe
concurrent access.
🧹 Nitpick comments (2)
internal/cache/memory.go (2)

29-38: Consider using time.NewTicker instead of time.After in the loop.

Using time.After in a loop creates a new timer on each iteration, which can lead to minor memory overhead. A time.Ticker is more idiomatic for periodic operations.

♻️ Suggested refactor
 func NewMemoryClient() *MemoryCache {
 	mc := &MemoryCache{
 		cache: make(map[string]cacheItem, 100),
 		stop:  make(chan struct{}),
 	}

 	go func() {
+		ticker := time.NewTicker(1 * time.Minute)
+		defer ticker.Stop()
+
 		for {
 			select {
 			case <-mc.stop:
 				return
-			case <-time.After(1 * time.Minute):
+			case <-ticker.C:
 				mc.cleanup(time.Now())
 			}
 		}
 	}()

 	return mc
 }

78-87: Potential panic if Close() is called multiple times.

Calling close() on an already-closed channel causes a panic. Consider guarding against double-close if the API permits multiple Close calls.

♻️ Suggested fix using sync.Once

Update the struct to include a sync.Once:

 type MemoryCache struct {
 	cache map[string]cacheItem
 	mu    sync.RWMutex
 	stop  chan struct{}
+	once  sync.Once
 }

Then guard the close:

 func (c *MemoryCache) Close() error {
-	close(c.stop)
+	c.once.Do(func() {
+		close(c.stop)
+	})

 	c.mu.Lock()
 	clear(c.cache)
 	c.mu.Unlock()

 	return nil
 }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d1150d2 and 5f42f08.

📒 Files selected for processing (2)
  • internal/cache/memory.go
  • internal/cache/memory_test.go
🧰 Additional context used
🧬 Code graph analysis (2)
internal/cache/memory_test.go (2)
internal/cache/memory.go (2)
  • NewMemoryClient (23-41)
  • MemoryCache (16-20)
internal/cache/cache.go (1)
  • ErrCacheMiss (10-10)
internal/cache/memory.go (1)
internal/cache/cache.go (1)
  • ErrCacheMiss (10-10)
🪛 GitHub Actions: Go Build and Publish
internal/cache/memory_test.go

[error] 164-164: TestMemoryCacheTTL: should_remove_a_cache_entry_after_TTL_via_cleanup failed due to race condition.


[error] 177-177: TestMemoryCacheTTL: concurrent operations caused test failure.

internal/cache/memory.go

[warning] 90-90: DATA RACE detected in MemoryCache.cleanup vs Set during TTL caching test.


[warning] 72-72: DATA RACE detected: MemoryCache.Set() concurrently with cleanup.

🔇 Additional comments (9)
internal/cache/memory.go (4)

10-13: LGTM!

The cacheItem struct cleanly encapsulates the cached value with its expiration timestamp.


16-20: LGTM!

The updated MemoryCache struct properly tracks the stop channel for graceful shutdown of the cleanup goroutine.


44-62: Potential TOCTOU race in Get between RLock check and Lock deletion.

Between releasing the read lock (line 47) and acquiring the write lock (line 54), another goroutine could have already deleted the expired item via cleanup or another Get call. The subsequent delete is safe (deleting non-existent key is a no-op), but worth noting for awareness.

The implementation is functionally correct for the use case.


102-111: LGTM!

The snapshot() function properly copies the cache under read lock for safe testing.

internal/cache/memory_test.go (5)

13-52: LGTM!

Well-structured table-driven tests covering both normal caching and TTL=0 skip behavior. The use of synctest.Test and proper cleanup with defer c.Close() is good practice.


54-111: LGTM!

Good coverage for cache read scenarios including cache hit and miss cases. The defer c.Close() ensures proper cleanup of the background goroutine.


113-149: LGTM!

The close test properly verifies that the cache is cleared after calling Close().


151-217: Test failures are caused by the data race in cleanup(), not test logic.

The pipeline failures at lines 164 and 177 are symptoms of the data race in memory.go's cleanup() function. Once that fix is applied (holding the lock during iteration), these tests should pass.

The test logic itself is correct—it properly uses synctest to control time advancement and validates both cleanup-based expiration and on-Get eviction.


6-6: testing/synctest is an experimental Go 1.24 feature requiring GOEXPERIMENT=synctest.

The testing/synctest package requires Go 1.24+ and must be enabled via the GOEXPERIMENT=synctest environment variable. Ensure your build and test environment supports this configuration.

Comment thread internal/cache/memory.go
@nDmitry nDmitry force-pushed the mem-cache-expires-at branch from 5f42f08 to f45a9a1 Compare January 13, 2026 09:29
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In @internal/cache/memory_test.go:
- Line 6: Wrap the TestMemoryCacheRead test in synctest.Test the same way as the
other tests: add/ensure the "testing/synctest" import is present and change the
body of TestMemoryCacheRead to call synctest.Test(t, func(t *testing.T) {
...existing test body... }), keeping the test name and signature unchanged and
preserving the existing assertions and setup inside the wrapped function.

In @internal/cache/memory.go:
- Around line 78-87: The Close method on MemoryCache panics if called twice
because it unconditionally closes the stop channel; add a closeOnce sync.Once
field to the MemoryCache struct and use closeOnce.Do(func(){ close(c.stop) })
inside MemoryCache.Close (leave the rest of cleanup logic intact) so the stop
channel is only closed once and subsequent Close calls are no-ops.
🧹 Nitpick comments (4)
internal/cache/memory.go (3)

29-38: Consider using time.NewTicker instead of time.After for repeated intervals.

time.After allocates a new timer on every iteration, which won't be garbage collected until it fires. A time.Ticker is more efficient for periodic operations.

♻️ Proposed fix
 func NewMemoryClient() *MemoryCache {
 	mc := &MemoryCache{
 		cache: make(map[string]cacheItem, 100),
 		stop:  make(chan struct{}),
 	}

 	go func() {
+		ticker := time.NewTicker(1 * time.Minute)
+		defer ticker.Stop()
 		for {
 			select {
 			case <-mc.stop:
 				return
-			case <-time.After(1 * time.Minute):
+			case <-ticker.C:
 				mc.cleanup(time.Now())
 			}
 		}
 	}()

 	return mc
 }

44-61: Potential TOCTOU race in expiration check.

The item is read under RLock, then the expiration check and deletion happen after releasing the lock. Between RUnlock and acquiring Lock for deletion, another goroutine could have already deleted or updated the key. While the delete is idempotent and won't cause crashes, you may want to re-check existence before deleting for correctness.

♻️ Safer pattern with re-check
 	if item.expiresAt.Before(time.Now()) {
 		c.mu.Lock()
-		delete(c.cache, key)
+		// Re-check in case another goroutine already handled it
+		if current, exists := c.cache[key]; exists && current.expiresAt.Before(time.Now()) {
+			delete(c.cache, key)
+		}
 		c.mu.Unlock()

 		return nil, ErrCacheMiss
 	}

89-109: Same TOCTOU pattern as in Get.

Keys collected under RLock could have been modified by the time you acquire Lock for deletion. This is benign (delete is idempotent), but if a key was re-set with a new expiration between the two locks, you'd incorrectly delete a valid entry.

♻️ Safer cleanup with re-check
 	if len(expiredKeys) > 0 {
 		c.mu.Lock()
 		for _, key := range expiredKeys {
-			delete(c.cache, key)
+			// Re-check expiration in case key was updated
+			if item, exists := c.cache[key]; exists && (item.expiresAt.Before(now) || item.expiresAt.Equal(now)) {
+				delete(c.cache, key)
+			}
 		}
 		c.mu.Unlock()
 	}
internal/cache/memory_test.go (1)

100-110: Inconsistent use of synctest.Test wrapper.

TestMemoryCacheRead doesn't use synctest.Test while other tests do. For consistency and to ensure the background goroutine behaves deterministically, consider wrapping this test as well.

♻️ Suggested change
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			c := tt.prepare()
-			defer c.Close()
-
-			v, err := c.Get(ctx, tt.key)
-
-			assert.Equal(t, tt.want, v)
-			assert.Equal(t, tt.err, err)
+			synctest.Test(t, func(t *testing.T) {
+				c := tt.prepare()
+				defer c.Close()
+
+				v, err := c.Get(ctx, tt.key)
+
+				assert.Equal(t, tt.want, v)
+				assert.Equal(t, tt.err, err)
+			})
 		})
 	}
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5f42f08 and f45a9a1.

📒 Files selected for processing (2)
  • internal/cache/memory.go
  • internal/cache/memory_test.go
🧰 Additional context used
🧬 Code graph analysis (2)
internal/cache/memory.go (1)
internal/cache/cache.go (1)
  • ErrCacheMiss (10-10)
internal/cache/memory_test.go (2)
internal/cache/memory.go (2)
  • NewMemoryClient (23-41)
  • MemoryCache (16-20)
internal/cache/cache.go (1)
  • ErrCacheMiss (10-10)
🔇 Additional comments (8)
internal/cache/memory.go (4)

10-13: LGTM!

Clean struct design with expiration timestamp for passive expiry checking.


15-20: LGTM!

The addition of the stop channel provides a clean mechanism to terminate the background cleanup goroutine.


64-76: LGTM!

Clean implementation with early return for TTL=0 case. The comment clearly documents the skip behavior.


111-121: LGTM!

The snapshot function correctly copies the cache under a read lock for testing purposes.

internal/cache/memory_test.go (4)

36-51: LGTM!

Clean table-driven test structure with proper resource cleanup via defer c.Close().


138-146: LGTM!

Good use of synctest.Test wrapper with require.NoError for the close operation and proper assertions on snapshot state.


176-186: LGTM!

Proper use of synctest.Test with time.Sleep and synctest.Wait() to test cleanup behavior deterministically.


190-217: LGTM!

Excellent test coverage for the in-Get expiration eviction path. This verifies that expired entries are removed on access before the background cleanup runs.

Comment thread internal/cache/memory_test.go
Comment thread internal/cache/memory.go
@nDmitry nDmitry force-pushed the mem-cache-expires-at branch from f45a9a1 to 0be466a Compare January 13, 2026 09:37
Comment thread internal/cache/memory.go
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In @internal/cache/memory.go:
- Around line 53-59: Get currently checks expiration with
item.expiresAt.Before(time.Now()) while cleanup uses Before || Equal, causing
items expiring exactly at now to be treated differently; update the expiration
check in Get (and any other checks around lines 89-98) to use the same logic as
cleanup — e.g. treat an item as expired when !item.expiresAt.After(now) (or
Before || Equal) — and use a single now := time.Now() within the function to
compare against item.expiresAt, then delete from c.cache and return ErrCacheMiss
as before.
- Around line 78-87: The Close method on MemoryCache currently closes the c.stop
channel directly which will panic if Close is called more than once; modify
MemoryCache to guard the channel close (e.g., add a sync.Once field like
closeOnce or guard with a boolean protected by mu) and invoke that once when
closing c.stop inside MemoryCache.Close, leaving the rest of the cleanup
(locking, clearing c.cache) as-is so repeated Close calls become no-ops rather
than causing a panic.
🧹 Nitpick comments (2)
internal/cache/memory.go (1)

29-38: Consider using time.Ticker instead of time.After in a loop.

Using time.After inside a loop creates a new timer channel on each iteration. time.Ticker is more efficient for recurring operations as it reuses the same underlying timer.

♻️ Suggested improvement
 func NewMemoryClient() *MemoryCache {
 	mc := &MemoryCache{
 		cache: make(map[string]cacheItem, 100),
 		stop:  make(chan struct{}),
 	}

 	go func() {
+		ticker := time.NewTicker(1 * time.Minute)
+		defer ticker.Stop()
+
 		for {
 			select {
 			case <-mc.stop:
 				return
-			case <-time.After(1 * time.Minute):
+			case <-ticker.C:
 				mc.cleanup(time.Now())
 			}
 		}
 	}()

 	return mc
 }
internal/cache/memory_test.go (1)

144-147: Redundant length assertion.

Line 146 checks len(tt.want) == len(snapshot) and line 147 checks full equality. The full equality check on line 147 already validates length, making line 146 redundant.

♻️ Suggested simplification
-			assert.Equal(t, len(tt.want), len(snapshot))
 			assert.Equal(t, tt.want, snapshot)
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f45a9a1 and 0be466a.

📒 Files selected for processing (2)
  • internal/cache/memory.go
  • internal/cache/memory_test.go
🧰 Additional context used
🧬 Code graph analysis (2)
internal/cache/memory.go (1)
internal/cache/cache.go (1)
  • ErrCacheMiss (10-10)
internal/cache/memory_test.go (2)
internal/cache/memory.go (2)
  • NewMemoryClient (23-41)
  • MemoryCache (16-20)
internal/cache/cache.go (1)
  • ErrCacheMiss (10-10)
🔇 Additional comments (6)
internal/cache/memory.go (3)

10-13: LGTM!

Clean struct definition for cache items with value and expiration timestamp.


64-76: LGTM!

Clear TTL=0 handling with appropriate comment. The implementation correctly stores the expiration timestamp.


111-121: LGTM!

Proper snapshot implementation with read lock and map copy for safe testing.

internal/cache/memory_test.go (3)

36-51: LGTM!

Clean table-driven test structure with proper setup/teardown using defer c.Close(). The synctest wrapper correctly handles time simulation.


192-219: LGTM!

Excellent test coverage for the Get-based expiration eviction path. This verifies that expired entries are removed on access even before the background cleanup runs.


6-6: No action required—the testing/synctest import is compatible.

The project uses Go 1.25, which is well above Go 1.24 where testing/synctest was introduced. The import is valid and standard; no GOEXPERIMENT=synctest flag is needed.

Comment thread internal/cache/memory.go
Comment thread internal/cache/memory.go
@nDmitry nDmitry merged commit 6dd19a3 into main Jan 13, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant