Cache System

This document explains the caching architecture of Beyou: what is cached and for how long, how writes invalidate stale data, why the AI agent sees the same cache the REST API does, and which in-memory caches exist outside Spring's cache manager entirely.

Cache at a glance

flowchart LR
  subgraph clients["Callers"]
    FE["Web / Mobile"]
    AG["AI agent tools"]
  end

  subgraph spring["Spring cache layer"]
    SC["@Cacheable services"]
    EC["UserCacheEvictService"]
    CM["CaffeineCacheManager"]
  end

  subgraph tiers["Tiers"]
    T1["Domain: 8 caches<br/>30 min · 500 max"]
    T2["Reference: xpByLevel<br/>no expiry · 100 max"]
    T3["Docs: 8 caches<br/>120 min · 30 max"]
  end

  FE --> SC
  AG --> SC
  SC --> CM
  EC --> CM
  CM --> T1
  CM --> T2
  CM --> T3
  SC -->|"miss"| DB[("PostgreSQL")]
  CM -->|"recordStats"| PR["Prometheus → Grafana"]

Key design decisions:

The three tiers

Domain caches (30 min TTL, 500 entries max)

Cache Method Key
categories CategoryService.getAllCategories userId
habits HabitService.getHabits userId
tasks TaskService.getAllTasks userId
goals GoalService.getAllGoals userId
routines DiaryRoutineService.getAllDiaryRoutines userId
routine DiaryRoutineService.getDiaryRoutineById userId + "_" + routineId
todayRoutine DiaryRoutineService.getTodayRoutineScheduled userId, null results never cached
schedules ScheduleService.findAll userId

All cached methods return DTOs, never JPA entities, so nothing lazy or detached ever comes out of a cache. The routine cache is the odd one: its composite key makes it the only domain cache Spring cannot evict per user, which shapes the whole eviction design below.

Reference cache (no expiry)

xpByLevel caches the level curve, one entry per level, with the annotation sitting directly on the repository interface. The underlying table is seeded by a repeatable Flyway migration, so the cached values only change on restart after a reseed. No TTL is the honest configuration for data that changes by migration only.

Docs caches (120 min TTL, 30 entries max)

Eight caches, two per docs area (list + detail), created lazily on first request and keyed by normalized locale (the blog list also keys by category and tag). The locale normalization exists because ?locale=EN, ?locale=en, and no locale at all must share one entry, and because a raw null key used to 400 every docs list request. These caches are only evicted by a docs import, which clears them wholesale.

The key expressions reach the normalizer as a bean, @docsLocale.normalize(#locale), and that form is a rule, not a style. The earlier T(beyou.beyouapp.backend.docs.DocsLocale).normalize(...) took the docs site down on 9 September 2026 with EL1005E: Type cannot be found. Spring's cache layer shares one evaluation context and creates its type locator on the first cache operation after boot, from the context class loader of whichever thread runs it. In the packaged jar only Boot's launcher loader sees the application classes, so a first thread carrying the system loader breaks every T(...) lookup until restart. The exploded classpath used locally and in e2e never shows it. A bean reference resolves through the bean factory and loads nothing, and an integration test now pins the locator from a foreign loader on purpose to keep it that way.

How eviction works

One user action can touch half the domain: checking a habit updates the habit, the routine, the user, and every linked category. Chasing individual entries would be fragile, so the design goes broad.

UserCacheEvictService has three entry points:

Method What it does Who calls it
evictAllUserCaches(userId) Drops the user's entry in all 7 user-keyed caches, then clears the shared routine cache Every interactive write
evictUserScopedCaches(userId) The same 7 drops, without touching the shared cache Batch jobs, per user in a loop
clearSharedRoutineCache() cache.clear() on routine, all users Batch jobs, once at the end

The split exists for a scaling reason the code spells out: the day-close and snapshot batch jobs loop over many users, and calling the interactive method in that loop would wipe the shared routine cache once per user. A thousand users would mean a thousand full clears. The batch path drops per-user entries in the loop and clears the shared cache exactly once, and integration tests pin that behavior.

The honest cost of the design: because routine uses composite keys, one user editing one routine flushes every user's cached routine details. On a single-host deployment with a 30-minute TTL that is acceptable; it is also the first thing to revisit if routine reads ever get hot.

Write paths that evict (always inside the service method, after the write): category, habit, task, goal, and schedule create/edit/delete; goal check, increase, and decrease; routine create, update, delete, add/remove items; live check and skip; snapshot check-ins, which also mutate XP; the two other paths that pay XP outside a routine, the study notebook's rewards and completing a calendar task; and turning an Ideas note into a task, which writes a task. The AI agent needs no separate list, as the next section explains.

The eviction annotations are deliberately duplicated between the two user-scoped methods rather than delegated, because a self-invocation would bypass the proxy and silently evict nothing. The comment in the class warns that a cache added to one block must be added to the other.

One cache, two front doors: REST and the AI agent

The agent's tools inject the same services the controllers use, so tool reads hit the same @Cacheable entries and tool writes run the same eviction. Consistency is by construction, not by discipline. Three details make it work:

What is deliberately not cached

Seven reads go to the database every time, and each service says why in its own javadoc. Three of them share one reason: the read takes a window of days, so a cache keyed on the user would answer one window with another window's rows, and a composite key would need its own eviction on every write that touches them.

Read Why it is not cached
Mood (MoodService) A date range per read over one row per day. Not worth a cache's staleness
XP history (XpHistoryService) A window per read, and the rows move on every check-in
Focus Mode (FocusService) The micro-task read is also a write (it materialises pinned names), so a cached answer would skip the very rows the person is about to tick
Study notebook (NotebookPageService) Computed from the whole notebook graph, and the autosave writes on every pause in typing
Study cycles (StudyCycleService) The reads also write (a GOAL block gets its concept fixed when it is first opened), and every pomodoro and page status change moves the numbers, so a cached answer would need evicting from the focus, page, board and quiz paths
Calendar (CalendarService) A range per read, and the overview also draws on routine snapshots, goals and mood entries, so every routine check, goal edit and mood tap would have to evict it. What it costs instead is a handful of indexed queries over at most nine weeks
Ideas board (QuickNoteService) One indexed query over at most two hundred short rows, and nothing else in the product is computed from them. A cache would save nothing and could serve a board from before a drag saved on another device

None of them is read by a cached method either, which is why their writes evict nothing. The exceptions write into cached data. Completing a calendar task and the notebook's rewards change the user's XP, and turning an Ideas note into a task creates a task, so those three paths run the ordinary per-user eviction.

Scheduled task cleanup

getAllTasks used to delete expired one-time tasks as a side effect, which made it uncacheable: the read was also a write. That logic moved to TaskCleanupScheduler, which runs daily and does the deleting in a transaction of its own. The read became pure, and the cache annotation became safe. It is a small story with a general moral: caching forces reads to be honest about being reads.

Monitoring

Every cache under the manager records stats, and Spring Boot's auto-configuration binds them to Micrometer, which Prometheus scrapes from the management port. Grafana shows them in the Cache row of the service-health dashboard: hit rate per cache, hits vs misses, sizes, evictions, and puts.

One caveat the dashboards inherit: Boot binds the caches that exist at startup. The nine registered caches qualify; the eight docs caches are created lazily on first request, so they can be absent from the panels until someone thinks to look for them.

The caches Spring doesn't manage

Several in-memory caches live outside the cache manager, invisible to the cache dashboard:

Cache Purpose Config
Rate-limit buckets bucket4j buckets per tier Caffeine, 10,000 max, 30 min after access
Login attempt counters The per-account lockout Caffeine, 50,000 max, expiry equals the lockout window, keyed by lowercased e-mail
Google public keys ID-token verification Cached internally by Google's verifier library
Federated provider keys OIDC ID-token verification: each issuer's JWKS, plus the time of its last refetch Caffeine, 32 issuers max, 6 h after write. An unknown kid refetches at most once a minute per issuer (OidcIdTokenVerifier)
LLM provider cooldowns The fallback chain skips a failing provider for a while Plain map: 300 s after a 429, 30 s after other errors
Active stream counters Caps concurrent agent SSE streams per user Plain map

The login-attempt cache carries a documented tradeoff: it is in-memory on purpose for a single-host deployment, which means a restart forgives all counters. The wrong-password path refreshes the window, so a persistent brute-forcer never ages out.

Measured impact

The numbers from when the layer shipped, measured with load tests against the same endpoints before and after:

Endpoints p50 p95 Throughput
Docs 21.9 ms → 5.3 ms 84.1 ms → 33.0 ms 455 → 974 req/s
Domain 30.9 ms → 16.0 ms 108.1 ms → 65.7 ms 257 → 401 req/s

What could be improved

Area Current state Note
routine cache eviction Global clear on any interactive routine write Tracking a user's routine ids would allow targeted eviction; worth it only if routine reads get hot
Docs cache metrics Lazily created, so possibly unbound Registering them eagerly would make the dashboard complete
Per-cache tuning All domain caches share 500 entries / 30 min The Grafana data exists to tune individually; nothing has demanded it yet
Search caching The docs search endpoint is uncached Fine at current traffic, cheap win later