Skip to content

feat(cache): migrate menu cache onto shared CacheManager (ADR 0002 PR 3)#36

Merged
jlc488 merged 3 commits into
mainfrom
feat/cache-menu-migration
May 31, 2026
Merged

feat(cache): migrate menu cache onto shared CacheManager (ADR 0002 PR 3)#36
jlc488 merged 3 commits into
mainfrom
feat/cache-menu-migration

Conversation

@jlc488

@jlc488 jlc488 commented May 31, 2026

Copy link
Copy Markdown
Contributor

ADR 0002 PR 3 — the per-user menu tree was caching in its own ConcurrentHashMap, which went stale across replicas. It now rides the shared CacheManager, so the menu cache inherits the consumer's devslab.kit.cache.type backend:

  • in-memoryConcurrentMapCacheManager (single node, same as before)
  • redis → distributed, correct across replicas, kit-owned JSON serialization
  • noneNoOpCacheManager, every read hits the DB (replaces the old "zero TTL disables the decorator" special case)

Changes

  • CachingMenuProvider rewritten on Spring Cache: cache.get(userId, loader) on CacheNames.MENU; invalidate/invalidateAll evict/clear.
  • MenuAutoConfiguration.menuProvider wraps DefaultMenuProvider when a CacheManager bean is present (ObjectProvider, optional), else returns the bare DB-backed provider — menus always work.
  • menu-core now depends on cache-api (CacheNames) + spring-context (CacheManager).
  • Removed devslab.kit.menu.cache-ttl; the menu cache TTL is governed by the shared devslab.kit.cache.ttl. DevslabKitProperties.Menu keeps only enabled.
  • SettingsController: menu section → cache section (type / ttlSeconds / keyPrefix). Wire-shape change — paired with admin-ui PR feat(tenants): TenantStatus enum + status endpoint + optional mode #17 (must merge together).
  • sample-app yaml: menu.cache-ttl removed; cache block added (in-memory default; flip type=redis to use the compose Redis).

Verification

./gradlew build --no-daemon green. sample-app boots the full context (menu provider + cache manager): SampleApplicationTests 4/0/0, BootstrapStatusEndpointTests 2/0/0.

Paired PR: devslab-kit-admin-ui#17 (Settings page menucache). Merge both together or the Settings page reads an absent field.

jlc488 added 3 commits May 31, 2026 16:50
…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().
@jlc488
jlc488 merged commit c22958d into main May 31, 2026
1 check 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