Skip to content

Redis cache for SDLC systems and SDLC project lookups - #14

Merged
andreasarf merged 18 commits into
mainfrom
claude/practical-dirac-t5uv58
Nov 1, 2026
Merged

andreasarf merged 18 commits into
mainfrom
claude/practical-dirac-t5uv58

Conversation

@andreasarf

Copy link
Copy Markdown
Owner

Summary

First of three Redis PRs: cache, then lock (Redisson), then rate limiter (Redisson). This one adds Spring Cache backed by Spring Data Redis (Lettuce), Boot's standard setup.

Cache What TTL
sdlc-systems SDLC systems by id, read on every create, update and SDLC call 10 min (resourceapi.cache.sdlc-systems-ttl)
external-projects GET /api/v2/projects/{id}/external answers per SDLC system and external id 5 min (resourceapi.cache.external-projects-ttl)
  • External lookups (ExternalProjectService). /external is cached, but "unknown" (404) answers are not, so a project is found as soon as it exists. The background sync bypasses the cache and writes the fresh answer into it (@CachePut).
  • Serialization. Values are stored as JSON with type information (GenericJacksonJsonRedisSerializer). Types are restricted to this app's and the JDK's classes, so data planted in Redis can't make the app create arbitrary classes.
  • Redis down. Failures are logged and skipped (LoggingCacheErrorHandler), so the API keeps working without the cache.
    • The Redis connect and command timeouts are 500 ms. Lettuce's default command timeout is 60 s, which would stall every cached request during an outage.
  • Boot settings kept. Defining a custom RedisCacheConfiguration bean makes Boot drop its spring.cache.redis.* settings, so they are applied explicitly. Without this, the key prefix was silently ignored.
  • Local dev. compose.yaml and spring-boot-docker-compose start Redis for ./mvnw spring-boot:run. This isn't packaged in the jar and isn't used by tests.
  • Docs. README section on Redis; docs/docker.md shows how to point the images at Redis.

Tests

They run against a real Redis 8 started with Testcontainers: one container per test run, shared by all contexts through RedisTestContainerInitializer (registered in spring.factories). Each context gets its own key prefix, because contexts have separate in-memory databases with overlapping ids.

  • CacheIntegrationTest:
    • three /external calls lead to one SDLC request, and the record is read back from Redis as ExternalProject;
    • 404s are not cached;
    • a sync bypasses the cache and refreshes it;
    • SDLC systems are served from the cache as entities;
    • keys carry the context's prefix and a TTL.
  • CacheUnavailableTest: with Redis unreachable, /external and POST still work, within the time limit.
  • Mutation checks. Each was checked to fail with its fix removed: the error handler, the sync bypass, and the key prefix.

Full suite: 89 tests pass. Tests now need Docker, which GitHub Actions provides.

Test plan

  • ./mvnw clean test (89 pass, real Redis)
  • ./mvnw spring-boot:run starts Redis from compose.yaml; a POST caches resourceapi::sdlc-systems::2
  • CI green (first CI run with Testcontainers)

Generated by Claude Code

- Spring Cache on Spring Data Redis (Lettuce), values as JSON with type
  information limited to this application's and the JDK's classes
- sdlc-systems (10m TTL): SDLC systems by id, read on every create,
  update and SDLC call
- external-projects (5m TTL): GET /projects/{id}/external answers per SDLC
  system and external id; "unknown" answers are not cached; a background
  sync bypasses the cache and refreshes the entry (ExternalProjectService)
- Redis failures are logged and skipped (LoggingCacheErrorHandler), with
  500ms Redis timeouts instead of Lettuce's 60s default, so an outage
  slows requests down instead of failing them
- The custom RedisCacheConfiguration applies Boot's spring.cache.redis.*
  settings (key prefix etc.), which defining the bean would otherwise drop
- compose.yaml + spring-boot-docker-compose start Redis for spring-boot:run
- Tests use a real Redis (Testcontainers, one container per run, a key
  prefix per context): served from cache, unknown not cached, sync
  refreshes, entities/records survive JSON, keys prefixed with TTL, Redis
  down still works (error handler, sync bypass and prefix each verified to
  fail without their fix)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
…r typing

- A sync that finds the project gone from its SDLC system evicts the
  cached answer (late @CacheEvict sees the raw Optional as #result)
- Start from Boot's cache defaults (spring.cache.redis.*) and only replace
  the value serializer, instead of re-implementing them in a
  RedisCacheConfiguration bean; null values disabled by property
- Type information in cached JSON limited to this application's classes,
  java.lang and java.time (not all of java.*)
- sdlc-systems uses sync=true, so concurrent misses do one database read
- compose.yaml publishes Redis on localhost:6379 for java -jar
- Test: sync of a project gone upstream removes the entry; the key-prefix
  test matches this context's prefix exactly

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
- Redisson client (plain library; its Boot starter targets Boot 3) on the
  same Redis connection details as the cache, connecting lazily so the app
  starts without Redis; object names prefixed (resourceapi.redisson.key-prefix)
- ProjectSyncLock: per-project RLock taken by POST /sync and released when
  the background sync ends (held by a random owner id, as it is released on
  another thread); expires after resourceapi.sync.lock-lease-time (60s); a
  concurrent sync gets 409; if Redis is unreachable the sync runs unlocked
- spring.cache.type=redis: Redisson brings a JCache provider that Boot would
  otherwise pick over Redis
- Tests (real Redis): lock held elsewhere -> 409 and no SDLC call; slow sync
  -> second refused, then allowed once the first released (verified to fail
  without the release); abandoned lock expires; keys carry the context's
  prefix; Redis down -> sync still runs (verified to fail without fail-open)
- Backpressure test uses two projects (the lock refuses the same one first)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
- ProjectSyncService.requestSync takes and releases the lock; the async
  work moves to ProjectSyncWorker, so no caller can start a sync unlocked
- A lock request that fails (e.g. times out after Redis applied it) keeps
  its lease, so the sync's end still tries to release it
- Redisson is only created for a standalone Redis; with Sentinel/Cluster
  the app starts and syncs run unlocked (was: startup failure)
- The task executor depends on Redisson, so on shutdown running syncs
  release their locks before Redisson stops
- No Redisson retries (a failed request took 2-3s), small connection pool
  (defaults were 24 idle / 64 max)
- Tests: Sentinel config starts and syncs unlocked; executor shutdown
  order; SDLC call count checked before the next sync can call; slow-sync
  test waits for its last sync; backpressure test polls instead of sleeping

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
- RateLimiters: Redisson RRateLimiter token buckets shared by all
  instances; created with the configured rate, updated when the
  configuration changed, expiring after an hour unused; requests are
  allowed when Redis is unreachable
- Calls to one SDLC system (resourceapi.rate-limit.sdlc.*, 10/s, waiting up
  to 1s): only real calls count, not cache hits; exceeded -> 503 +
  Retry-After (our own limit), logged by a background sync
- POST /sync per project (resourceapi.rate-limit.sync.*, 6/min): checked
  after the 404 and lock checks, so refused requests don't use a permit;
  exceeded -> 429 + Retry-After
- Tests (real Redis): third uncached call refused and not sent, permits
  shared with another instance and refilled, cache hits don't count,
  changed rate applied (verified to fail without the update), sync 429 with
  Retry-After, 409s don't use permits; Redis down still allows (verified
  to fail without fail-open)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
claude added 11 commits October 31, 2026 10:31
…ted limits

- Redisson's RRateLimiter is a sliding window (a used permit returns one
  period later): Retry-After is now the full period (was period/permits,
  so clients retried into another 429); README says sliding window
- A limiter is set up once per instance (trySetRate no longer on every
  call), and again only when Redis dropped it after expiring; it is marked
  set up only after that succeeded, so a failed update is retried
- The set of set-up limiters is bounded (LRU, 10,000)
- Rate limit properties are validated at startup (@positive permits,
  required period and max-wait)
- README: a sync refused for a full executor (503) does use a permit
- Tests: removed/expired limiter is set up again (verified to fail
  without it); invalid limits rejected; Retry-After is one full window

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
…positories

- The CachingConfigurer (error handler) is its own infrastructure-role
  configuration, so its early creation by the caching infrastructure no
  longer logs 'not eligible for getting processed by all BeanPostProcessors'
- spring.data.redis.repositories.enabled=false: Redis is only a cache, so
  the JPA repositories aren't inspected as Redis repository candidates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
# Conflicts:
#	src/main/resources/application.properties
They are deprecated on the server config (logged as warnings at startup).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
…lient

- external-projects-missing cache (30s): a project the SDLC system doesn't
  know is answered from the cache instead of asking again each time, and
  found soon after it is created there; a sync replaces it with the
  project. Used directly through the CacheManager, so failures are
  caught and logged like the cache error handler does
- ClientRateLimitFilter on /api/* (FilterRegistrationBean, after the trace
  filter): requests per client IP across all instances (100/s, 429 +
  Retry-After); server.forward-headers-strategy=native takes the client IP
  from X-Forwarded-For only from internal proxy addresses
- Tests: unknown answer remembered then found after expiry, sync clears it;
  per-client 429 with Retry-After and X-Trace-Id, separate per client,
  non-API paths unlimited; the repeated-lookup test counts calls after the
  first lookup, as the HTTP client may retry a GET on a stale connection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
Values in the Redis cache are rebuilt from JSON by their type name, which a
native image only allows for types registered for reflection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
Startup validation reads the record fields reflectively; the native image
failed to start with MissingReflectionRegistrationError on Limit.permits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
…ases

# Conflicts:
#	src/main/java/com/company/resourceapi/config/RateLimitProperties.java
…ed 429

- "Unknown project" is cached as Spring's null value in the external-projects
  cache with a per-entry TTL (30s unknown, 5m found), replacing the separate
  cache and the hand-written lookups; a sync simply @CachePut-replaces it.
- RateLimiters stops asking Redis for 10s after a failure, so an outage
  doesn't add a timeout to every request.
- Limiters expire 10 periods after last use (at least a minute) instead of
  an hour, so per-IP limiters don't pile up.
- The per-client filter answers through the shared exception handler;
  refused callers are logged at DEBUG.
- README: server.tomcat.remoteip.internal-proxies for the per-IP limit.
- Tests: longer negative-cache TTL, no timing-sensitive 404 assertion,
  delta-based call counts, unit test for the outage pause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
@andreasarf
andreasarf added this pull request to stack #18 October 31, 2026 14:21
@andreasarf
andreasarf merged commit 39f3d56 into main Nov 1, 2026
6 of 8 checks 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.

2 participants