Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,30 @@ public static class Menu {

private boolean enabled = true;

/**
* How long a {@link kr.devslab.kit.menu.MenuProvider} keeps a
* per-user menu tree before re-querying the database. Set to
* {@code PT0S} (or any zero / negative duration) to disable the
* cache entirely — useful in tests where menu / permission
* mutations should be visible immediately.
*/
private java.time.Duration cacheTtl = java.time.Duration.ofMinutes(5);

public boolean isEnabled() {
return enabled;
}

public void setEnabled(boolean enabled) {
this.enabled = enabled;
}

public java.time.Duration getCacheTtl() {
return cacheTtl;
}

public void setCacheTtl(java.time.Duration cacheTtl) {
this.cacheTtl = cacheTtl;
}
}

public static class Audit {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package kr.devslab.kit.autoconfigure;

import jakarta.persistence.EntityManager;
import java.time.Clock;
import java.time.Duration;
import kr.devslab.kit.access.PermissionChecker;
import kr.devslab.kit.menu.MenuProvider;
import kr.devslab.kit.menu.core.repository.JpaPlatformMenuRepository;
import kr.devslab.kit.menu.core.service.CachingMenuProvider;
import kr.devslab.kit.menu.core.service.DefaultMenuProvider;
import kr.devslab.kit.menu.core.service.MenuTreeBuilder;
import kr.devslab.kit.menu.core.service.PermissionBasedMenuFilter;
Expand All @@ -12,6 +15,7 @@
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;

@AutoConfiguration(afterName = {
Expand All @@ -25,6 +29,7 @@
havingValue = "true",
matchIfMissing = true
)
@EnableConfigurationProperties(DevslabKitProperties.class)
public class MenuAutoConfiguration {

@Bean
Expand All @@ -40,14 +45,28 @@ public PermissionBasedMenuFilter permissionBasedMenuFilter(PermissionChecker per
return new PermissionBasedMenuFilter(permissionChecker);
}

/**
* Wraps the database-backed {@link DefaultMenuProvider} in a
* {@link CachingMenuProvider} when {@code devslab.kit.menu.cache-ttl} is
* positive (default 5m). A zero / negative TTL skips the cache decorator
* entirely, which is the right behaviour for tests that mutate menus or
* permissions and expect the next read to see the change immediately.
*/
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean({PermissionBasedMenuFilter.class})
public MenuProvider menuProvider(
JpaPlatformMenuRepository repository,
MenuTreeBuilder menuTreeBuilder,
PermissionBasedMenuFilter filter
PermissionBasedMenuFilter filter,
DevslabKitProperties properties,
Clock clock
) {
return new DefaultMenuProvider(repository, menuTreeBuilder, filter);
MenuProvider base = new DefaultMenuProvider(repository, menuTreeBuilder, filter);
Duration ttl = properties.getMenu().getCacheTtl();
if (ttl == null || ttl.isZero() || ttl.isNegative()) {
return base;
}
return new CachingMenuProvider(base, ttl, clock);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package kr.devslab.kit.menu.core.service;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import kr.devslab.kit.identity.CurrentUser;
import kr.devslab.kit.menu.MenuProvider;
import kr.devslab.kit.menu.MenuTree;

/**
* {@link MenuProvider} decorator that caches the per-user menu tree for a
* configurable TTL.
*
* <p>Caching keyed by {@code CurrentUser.id()} — the menu tree the delegate
* returns is already permission-filtered, so the result varies per user
* even within the same tenant. Per-user keys keep the cache correct without
* having to invalidate on tenant-wide menu edits (the TTL handles that).
*
* <p>Implementation deliberately small: a {@link ConcurrentHashMap} with a
* per-entry expiry timestamp, no background eviction thread, no LRU bound.
* Entries expire lazily on read; stale entries linger until a read evicts
* them. For the usage we have today (a few dozen admins per tenant, menus
* loaded once per session) this is plenty. Swap in Caffeine or a Spring
* Cache if the access pattern outgrows it.
*
* <p>Manual invalidation is exposed for admin tooling that mutates menus
* and wants the next read to skip the cache.
*/
public class CachingMenuProvider implements MenuProvider {

private final MenuProvider delegate;
private final Duration ttl;
private final Clock clock;
private final Map<UUID, CacheEntry> cache = new ConcurrentHashMap<>();

public CachingMenuProvider(MenuProvider delegate, Duration ttl, Clock clock) {
this.delegate = delegate;
this.ttl = ttl;
this.clock = clock;
}

@Override
public MenuTree menusFor(CurrentUser user) {
UUID key = user.id().value();
Instant now = Instant.now(clock);
CacheEntry hit = cache.get(key);
if (hit != null && hit.expiresAt().isAfter(now)) {
return hit.tree();
}
// Recompute via the delegate. ConcurrentHashMap.put has racing-thunderr
// semantics — two callers may both miss and both invoke the delegate
// briefly; both writes are idempotent. Worth it to avoid holding a
// lock across the DB round-trip.
MenuTree fresh = delegate.menusFor(user);
cache.put(key, new CacheEntry(fresh, now.plus(ttl)));
return fresh;
}

public void invalidate(UUID userId) {
cache.remove(userId);
}

public void invalidateAll() {
cache.clear();
}

private record CacheEntry(MenuTree tree, Instant expiresAt) {
}
}
Loading