feat(cache): migrate menu cache onto shared CacheManager (ADR 0002 PR 3)#36
Merged
Conversation
…2 PR 3) The per-user menu tree rode its own ConcurrentHashMap, which went stale across replicas. It now uses the shared CacheManager, so it inherits the consumer's devslab.kit.cache.type backend: in-memory (single node), redis (distributed, correct across replicas), or none (every read hits the DB — replaces the old "zero TTL disables the decorator" special case). - CachingMenuProvider: rewritten on Spring Cache (cache.get(userId, loader) on CacheNames.MENU); invalidate/invalidateAll now evict/clear that cache. - MenuAutoConfiguration: wraps DefaultMenuProvider when a CacheManager bean is present (ObjectProvider, optional), else returns the bare DB-backed provider. - menu-core: depends on cache-api (CacheNames) + spring-context (CacheManager). - Removed devslab.kit.menu.cache-ttl — the menu cache TTL is now governed by the shared devslab.kit.cache.ttl. DevslabKitProperties.Menu keeps only `enabled`. - SettingsController: menu section → cache section (type / ttlSeconds / keyPrefix). NOTE: changes the /settings wire shape — paired admin-ui PR updates the type + Settings view to match. - sample-app yaml: menu.cache-ttl removed; cache block added (in-memory default, flip type=redis to use the compose Redis). Verified: ./gradlew build --no-daemon green; sample-app boots the full context (menu provider + cache manager) — SampleApplicationTests 4/0/0, BootstrapStatusEndpointTests 2/0/0.
…Clock
JjwtAuthTokenService.issue() stamped iat/exp from the injected Clock, but
parse() built the JJWT parser without .clock(), so expiration was validated
against the real system clock. Two consequences:
1. Asymmetric time source — issue() and parse() could disagree on "now".
2. The injected Clock (the whole reason it's a constructor arg) was ignored on
the read path, making token validation untestable with a fixed clock.
This surfaced as a wall-clock-dependent CI failure: JjwtAuthTokenServiceTest
fixes the clock at 2026-05-31T00:00:00Z with an 8h TTL (exp 08:00:00Z). Any CI
run after 08:00 UTC saw the token as already expired -> parse() returned empty
-> orElseThrow() threw NoSuchElementException. The suite passed only when it
happened to run before 08:00 UTC (and locally was masked by Gradle build-cache
serving identity-core:test UP-TO-DATE). It is a latent time-bomb on main, not
specific to any feature branch.
Fix: pass the injected clock to the parser —
.clock(() -> Date.from(Instant.now(clock)))
io.jsonwebtoken.Clock is a `Date now()` functional interface, so java.time.Clock
adapts with a lambda. Production behaviour is unchanged (the runtime injects
Clock.systemUTC(), identical on both paths); only the injected-clock path is
corrected.
Regression locks added to JjwtAuthTokenServiceTest (now 4 tests, deterministic
regardless of when CI runs):
- parseHonorsInjectedClock_acceptsTokenThatRealClockWouldReject: clock fixed in
2020; a token whose 8h window closed years ago in wall-clock terms must still
parse, proving the injected clock governs expiry (fails if .clock() is removed).
- parseRejectsTokenExpiredPerInjectedClock: a reader clock past the TTL rejects
the token.
Verified: ./gradlew build --no-daemon green, all 17 test tasks executed fresh;
JjwtAuthTokenServiceTest 4/0/0. Only one Jwts.parser() site exists in main
source — no other parser is missing .clock().
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ADR 0002 PR 3 — the per-user menu tree was caching in its own
ConcurrentHashMap, which went stale across replicas. It now rides the sharedCacheManager, so the menu cache inherits the consumer'sdevslab.kit.cache.typebackend:in-memory→ConcurrentMapCacheManager(single node, same as before)redis→ distributed, correct across replicas, kit-owned JSON serializationnone→NoOpCacheManager, every read hits the DB (replaces the old "zero TTL disables the decorator" special case)Changes
CachingMenuProviderrewritten on Spring Cache:cache.get(userId, loader)onCacheNames.MENU;invalidate/invalidateAllevict/clear.MenuAutoConfiguration.menuProviderwrapsDefaultMenuProviderwhen aCacheManagerbean is present (ObjectProvider, optional), else returns the bare DB-backed provider — menus always work.cache-api(CacheNames) +spring-context(CacheManager).devslab.kit.menu.cache-ttl; the menu cache TTL is governed by the shareddevslab.kit.cache.ttl.DevslabKitProperties.Menukeeps onlyenabled.menusection →cachesection (type/ttlSeconds/keyPrefix). Wire-shape change — paired with admin-ui PR feat(tenants): TenantStatus enum + status endpoint + optional mode #17 (must merge together).menu.cache-ttlremoved;cacheblock added (in-memory default; fliptype=redisto use the compose Redis).Verification
./gradlew build --no-daemongreen. sample-app boots the full context (menu provider + cache manager):SampleApplicationTests4/0/0,BootstrapStatusEndpointTests2/0/0.