Skip to content

refactor: use new cache store in services#912

Merged
steveiliop56 merged 4 commits into
mainfrom
refactor/service-cache
May 31, 2026
Merged

refactor: use new cache store in services#912
steveiliop56 merged 4 commits into
mainfrom
refactor/service-cache

Conversation

@steveiliop56
Copy link
Copy Markdown
Member

@steveiliop56 steveiliop56 commented May 29, 2026

Summary by CodeRabbit

  • Refactor
    • Authentication state moved to a unified cache system, improving consistency, reliability, and performance of login rate-limiting, OAuth handling, and LDAP group lookups.
  • Tests
    • Added comprehensive cache unit tests and updated auth-related tests to align with the new cache-based behavior (including clearer login-attempt reset).
  • Chores
    • Added quality targets for static vetting and race-detection testing.

@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label May 29, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 29, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 65b26af3-1e55-4855-bf82-6680a1dc9559

📥 Commits

Reviewing files that changed from the base of the PR and between ac9689d and fe84638.

📒 Files selected for processing (1)
  • internal/service/cache_store.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/service/cache_store.go

📝 Walkthrough

Walkthrough

Adds a generic CacheStore (TTL, bounded eviction, WithLock) with unit tests; refactors AuthService to use cache stores for login attempts, OAuth pending sessions, and LDAP group caching; updates lockdown handling and test setup; Makefile gains vet and race test targets.

Changes

Auth Service Cache-Based State Management

Layer / File(s) Summary
Generic cache store implementation
internal/service/cache_store.go
New generic CacheStore[T] with per-entry TTLs, bounded-size eviction (order tracking), WithLock atomic callback, Set, Get, Update, Delete, Sweep, Size, and Clear.
CacheStore unit tests
internal/service/cache_store_test.go
Comprehensive tests for Get, Update, Delete, Sweep, eviction policies, Size/Clear, WithLock atomicity, and a concurrency stress test.
AuthService data structures and imports
internal/service/auth_service.go
Removes unused import; adds LoginAttempt type and refactors AuthService to hold ctx, embedded lockdown, and typed caches (login/oauth/ldap) instead of prior maps/mutexes.
AuthService initialization and background sweep
internal/service/auth_service.go
NewAuthService initializes login, OAuth, and LDAP cache stores and starts a background goroutine to sweep caches until shutdown.
LDAP group caching via cache store
internal/service/auth_service.go
GetLDAPUser reads/writes LDAP group entries via the LDAP cache with TTL from config.LDAP.GroupCacheTTL.
Login rate-limiting via cache store
internal/service/auth_service.go
IsAccountLocked and RecordLoginAttempt use the login cache and WithLock/Set to track per-identifier attempts and trigger lockdown based on cache size and thresholds.
Lockdown lifecycle and testing utilities
internal/service/auth_service.go
lockdownMode uses embedded lockdown mutex and exits on auth.ctx.Done(); ClearRateLimitsTestingOnly removed and replaced by ClearLoginAttempts() which clears the login cache; IsInLockdown() added.
OAuth session management via cache store
internal/service/auth_service.go
NewOAuthSession, GetOAuthToken, EndOAuthSession, and GetOAuthPendingSession persist/retrieve pending OAuth sessions via the OAuth cache (Set, Get, Update, Delete), removing prior map-based routines.
Test suite updates and Makefile
internal/controller/user_controller_test.go, internal/middleware/context_middleware_test.go, Makefile
Tests now call ClearLoginAttempts() in setup instead of the removed helper; Makefile adds vet and test-race phony targets.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

size:L, lgtm

Poem

🐰 I hopped through maps and mutex vines,

I planted caches in tidy lines,
TTLs ticking, eviction at play,
Tests sprint and sweep the old mess away,
A tiny rabbit cheers this refactor today.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change as a refactor to use a new cache store implementation in services, which aligns with the core modifications across auth_service.go and related test files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/service-cache

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.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 29, 2026

Codecov Report

❌ Patch coverage is 52.30769% with 93 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/service/auth_service.go 0.00% 92 Missing ⚠️
internal/service/cache_store.go 99.02% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Contributor

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/service/auth_service.go (2)

256-297: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Data race: lockdown.active read without lock at line 262.

Same issue as IsAccountLocked — this read should be protected by auth.lockdown.mu.RLock().

Proposed fix
 	if auth.caches.login.Size() >= MaxLoginAttemptRecords {
+		auth.lockdown.mu.RLock()
+		active := auth.lockdown.active
+		auth.lockdown.mu.RUnlock()
-		if auth.lockdown.active {
+		if active {
 			return
 		}
 		go auth.lockdownMode()
 		return
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/service/auth_service.go` around lines 256 - 297, In
RecordLoginAttempt you read lockdown.active without synchronization causing a
data race; wrap the check of auth.lockdown.active with a read lock (use
auth.lockdown.mu.RLock() before the check and defer auth.lockdown.mu.RUnlock()
immediately after) so the read is protected, leaving the subsequent goroutine
spawn of auth.lockdownMode() unchanged; ensure you reference the lockdown field
on the AuthService (auth.lockdown), the RecordLoginAttempt method, and use the
existing mutex names (mu, RLock/RUnlock) for consistency.

233-254: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Data race: lockdown.active and lockdown.until read without holding the mutex.

lockdownMode writes these fields under auth.lockdown.mu.Lock(), but IsAccountLocked reads them without any lock. This is a data race that can cause torn reads or stale values under concurrent access.

Proposed fix
 func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
+	auth.lockdown.mu.RLock()
+	active := auth.lockdown.active
+	until := auth.lockdown.until
+	auth.lockdown.mu.RUnlock()
+
-	if auth.lockdown.active {
-		remaining := int(time.Until(auth.lockdown.until).Seconds())
+	if active {
+		remaining := int(time.Until(until).Seconds())
 		return true, remaining
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/service/auth_service.go` around lines 233 - 254, IsAccountLocked
currently reads auth.lockdown.active and auth.lockdown.until without
synchronizing with the mutex used by lockdownMode; acquire the same mutex around
those reads to avoid a data race (use auth.lockdown.mu.RLock()/RUnlock() if it's
an RWMutex or Lock()/Unlock() if it's a plain Mutex), copy the needed values
(active and until) into local variables while holding the lock, release the lock
and then compute remaining seconds and return as before; update the code paths
in IsAccountLocked that reference auth.lockdown.active and auth.lockdown.until
to use the locally copied values.
🧹 Nitpick comments (1)
internal/service/cache_store.go (1)

104-134: 💤 Low value

MutateWithTTL doesn't handle ttl=0 consistently with Set.

In Set, ttl > 0 is checked before setting expiresAt. Here, ttl=0 causes expiresAt = time.Now(), making the entry immediately expired. If a mutator returns ttl=0 intending "no expiration," this will break.

Proposed fix
 	newValue, ttl, shouldKeep := mutator(entry.value)

 	if !shouldKeep {
 		delete(cs.cache, key)
 		return true
 	}

-	expiresAt := time.Now().Add(ttl)
+	var expiresAt *time.Time
+	if ttl > 0 {
+		exp := time.Now().Add(ttl)
+		expiresAt = &exp
+	}

 	cs.cache[key] = cacheEntry[T]{
 		value:     newValue,
-		expiresAt: &expiresAt,
+		expiresAt: expiresAt,
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/service/cache_store.go` around lines 104 - 134, MutateWithTTL
currently sets expiresAt unconditionally which makes ttl==0 produce an
immediately expired entry; update MutateWithTTL (in type CacheStore[T]) to
mirror Set's behavior: only compute and assign expiresAt when ttl > 0 (use
time.Now().Add(ttl)); otherwise set expiresAt to nil to represent no expiration,
and keep the rest of the logic (deleting on !shouldKeep and locking via cs.mu)
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/service/cache_store.go`:
- Around line 146-167: evictOne currently returns without evicting when every
entry has expiresAt == nil (oldestKey stays empty), allowing the cache to exceed
maxSize; update CacheStore.evictOne to fall back to arbitrary eviction: if no
expiring entry was found but cs.cache is non-empty, pick and delete any one key
(e.g., the first key encountered via range) and return true; keep existing
behavior for deleting expired entries and the existing oldest-expiry logic so
evictOne always removes an entry when cache is non-empty.

---

Outside diff comments:
In `@internal/service/auth_service.go`:
- Around line 256-297: In RecordLoginAttempt you read lockdown.active without
synchronization causing a data race; wrap the check of auth.lockdown.active with
a read lock (use auth.lockdown.mu.RLock() before the check and defer
auth.lockdown.mu.RUnlock() immediately after) so the read is protected, leaving
the subsequent goroutine spawn of auth.lockdownMode() unchanged; ensure you
reference the lockdown field on the AuthService (auth.lockdown), the
RecordLoginAttempt method, and use the existing mutex names (mu, RLock/RUnlock)
for consistency.
- Around line 233-254: IsAccountLocked currently reads auth.lockdown.active and
auth.lockdown.until without synchronizing with the mutex used by lockdownMode;
acquire the same mutex around those reads to avoid a data race (use
auth.lockdown.mu.RLock()/RUnlock() if it's an RWMutex or Lock()/Unlock() if it's
a plain Mutex), copy the needed values (active and until) into local variables
while holding the lock, release the lock and then compute remaining seconds and
return as before; update the code paths in IsAccountLocked that reference
auth.lockdown.active and auth.lockdown.until to use the locally copied values.

---

Nitpick comments:
In `@internal/service/cache_store.go`:
- Around line 104-134: MutateWithTTL currently sets expiresAt unconditionally
which makes ttl==0 produce an immediately expired entry; update MutateWithTTL
(in type CacheStore[T]) to mirror Set's behavior: only compute and assign
expiresAt when ttl > 0 (use time.Now().Add(ttl)); otherwise set expiresAt to nil
to represent no expiration, and keep the rest of the logic (deleting on
!shouldKeep and locking via cs.mu) unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 084b4f04-f090-443c-8c85-2bcd31dad0df

📥 Commits

Reviewing files that changed from the base of the PR and between faee58c and ed94490.

📒 Files selected for processing (4)
  • internal/controller/user_controller_test.go
  • internal/middleware/context_middleware_test.go
  • internal/service/auth_service.go
  • internal/service/cache_store.go

Comment thread internal/service/cache_store.go
@steveiliop56 steveiliop56 marked this pull request as draft May 29, 2026 20:41
@steveiliop56 steveiliop56 marked this pull request as ready for review May 31, 2026 15:22
@steveiliop56
Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 31, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels May 31, 2026
Copy link
Copy Markdown
Contributor

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/service/cache_store.go (2)

155-180: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep order in sync in the TTL-based eviction paths.

The expired-entry and oldestKey branches delete from cs.cache directly but never remove the evicted key from cs.order. Once that stale key reaches the front of order, the fallback branch returns true without freeing a real slot, so bounded stores like internal/service/auth_service.go:113 can grow past their configured cap.

Proposed fix
 	for k, e := range cs.cache {
 		if e.expiresAt != nil && now.After(*e.expiresAt) {
-			delete(cs.cache, k)
+			cs.deleteCallback(k)
 			return true
 		}
 		if e.expiresAt != nil && (oldestExp == nil || e.expiresAt.Before(*oldestExp)) {
 			oldestKey, oldestExp = k, e.expiresAt
 		}
 	}
@@
 	if oldestKey != "" {
-		delete(cs.cache, oldestKey)
+		cs.deleteCallback(oldestKey)
 		return true
-	} else {
-		if len(cs.order) > 0 {
-			firstKey := cs.order[0]
-			cs.order = cs.order[1:]
-			delete(cs.cache, firstKey)
-			return true
-		}
 	}
+
+	for len(cs.order) > 0 {
+		firstKey := cs.order[0]
+		cs.order = cs.order[1:]
+		if _, exists := cs.cache[firstKey]; exists {
+			delete(cs.cache, firstKey)
+			return true
+		}
+	}
 
 	return false
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/service/cache_store.go` around lines 155 - 180, In evictOne, the
TTL-based branches remove keys from cs.cache but do not remove the same keys
from cs.order, causing capacity accounting to break; update the expired-entry
branch and the oldestKey branch in the evictOne method to also remove the
evicted key from cs.order (e.g., locate the key in cs.order and splice it out)
so the in-memory order slice and map remain in sync; reference the evictOne
method, the cs.cache map and the cs.order slice when making the change.

81-100: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid appending duplicate keys to order on overwrite.

Set always appends key at Line 100, even when the key already exists. That leaves stale duplicates in cs.order; after a delete or later fallback eviction, evictOne can pop a key that's no longer in cs.cache and the next insert pushes the store past maxSize.

Proposed fix
 func (cs *CacheStore[T]) setCallback(key string, value T, ttl time.Duration) {
+	_, exists := cs.cache[key]
 	if cs.maxSize > 0 {
-		if _, exists := cs.cache[key]; !exists && len(cs.cache) >= cs.maxSize {
+		if !exists && len(cs.cache) >= cs.maxSize {
 			cs.evictOne()
 		}
 	}
@@
 	cs.cache[key] = cacheEntry[T]{
 		value:     value,
 		expiresAt: expiresAt,
 	}
 
-	cs.order = append(cs.order, key)
+	if !exists {
+		cs.order = append(cs.order, key)
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/service/cache_store.go` around lines 81 - 100, The setCallback
method is appending keys to cs.order unconditionally which allows duplicate
stale entries; update CacheStore.setCallback to check if key already exists in
cs.cache (or track presence via cs.lookup) and only append to cs.order when
inserting a new key (or remove the old position before re-appending), so that
cs.order contains each active key once and evictOne will not encounter stale
duplicates; reference CacheStore.setCallback, cs.cache, cs.order and evictOne
when making the change.
🧹 Nitpick comments (1)
internal/service/cache_store_test.go (1)

230-301: ⚡ Quick win

Add a regression case for stale order entries.

The current eviction tests only cover the happy-path FIFO/TTL behavior. They don't exercise overwrite/delete or TTL-driven eviction followed by another fallback eviction, which is the path where order desynchronization lets Size() exceed maxSize.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/service/cache_store_test.go` around lines 230 - 301, Add a
regression test to exercise the stale "order" desynchronization path so Size()
cannot exceed maxSize: create a new test case in TestCacheStoreEviction (use
NewCacheStore, Set, Get, Size) that inserts entries to fill the cache, then
causes one entry to expire (short TTL + sleep) and then performs another Set
that would trigger fallback eviction; assert the expired key is absent, the
cache only contains maxSize keys, and Size() equals the configured max (e.g.,
2). Ensure the sequence overwrites/deletes or relies on TTL-driven eviction
followed by a normal eviction so any stale ordering state is exercised and
cleaned up.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Makefile`:
- Around line 70-72: The Makefile's .PHONY list is missing the target
"test-race", so if a file or directory named test-race appears the recipe won't
run; update the .PHONY declaration to include test-race alongside vet (i.e., add
"test-race" to the .PHONY list) so the test-race target is always treated as a
phony Makefile target and its recipe (go test -race ./...) always executes.

---

Outside diff comments:
In `@internal/service/cache_store.go`:
- Around line 155-180: In evictOne, the TTL-based branches remove keys from
cs.cache but do not remove the same keys from cs.order, causing capacity
accounting to break; update the expired-entry branch and the oldestKey branch in
the evictOne method to also remove the evicted key from cs.order (e.g., locate
the key in cs.order and splice it out) so the in-memory order slice and map
remain in sync; reference the evictOne method, the cs.cache map and the cs.order
slice when making the change.
- Around line 81-100: The setCallback method is appending keys to cs.order
unconditionally which allows duplicate stale entries; update
CacheStore.setCallback to check if key already exists in cs.cache (or track
presence via cs.lookup) and only append to cs.order when inserting a new key (or
remove the old position before re-appending), so that cs.order contains each
active key once and evictOne will not encounter stale duplicates; reference
CacheStore.setCallback, cs.cache, cs.order and evictOne when making the change.

---

Nitpick comments:
In `@internal/service/cache_store_test.go`:
- Around line 230-301: Add a regression test to exercise the stale "order"
desynchronization path so Size() cannot exceed maxSize: create a new test case
in TestCacheStoreEviction (use NewCacheStore, Set, Get, Size) that inserts
entries to fill the cache, then causes one entry to expire (short TTL + sleep)
and then performs another Set that would trigger fallback eviction; assert the
expired key is absent, the cache only contains maxSize keys, and Size() equals
the configured max (e.g., 2). Ensure the sequence overwrites/deletes or relies
on TTL-driven eviction followed by a normal eviction so any stale ordering state
is exercised and cleaned up.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9b621d43-b807-43be-9e17-55720f49ef1d

📥 Commits

Reviewing files that changed from the base of the PR and between ed94490 and ac9689d.

📒 Files selected for processing (4)
  • Makefile
  • internal/service/auth_service.go
  • internal/service/cache_store.go
  • internal/service/cache_store_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/service/auth_service.go

Comment thread Makefile
@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label May 31, 2026
@steveiliop56
Copy link
Copy Markdown
Member Author

lgtm

@steveiliop56 steveiliop56 merged commit dac8445 into main May 31, 2026
5 checks passed
@steveiliop56 steveiliop56 deleted the refactor/service-cache branch May 31, 2026 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lgtm This PR has been approved by a maintainer size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants