Repository navigation
Redis cache for SDLC systems and SDLC project lookups - #14
Merged
Merged
Conversation
- 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
…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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
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.
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.
sdlc-systemsresourceapi.cache.sdlc-systems-ttl)external-projectsGET /api/v2/projects/{id}/externalanswers per SDLC system and external idresourceapi.cache.external-projects-ttl)ExternalProjectService)./externalis 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).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.LoggingCacheErrorHandler), so the API keeps working without the cache.RedisCacheConfigurationbean makes Boot drop itsspring.cache.redis.*settings, so they are applied explicitly. Without this, the key prefix was silently ignored.compose.yamlandspring-boot-docker-composestart Redis for./mvnw spring-boot:run. This isn't packaged in the jar and isn't used by tests.docs/docker.mdshows 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 inspring.factories). Each context gets its own key prefix, because contexts have separate in-memory databases with overlapping ids.CacheIntegrationTest:/externalcalls lead to one SDLC request, and the record is read back from Redis asExternalProject;CacheUnavailableTest: with Redis unreachable,/externaland POST still work, within the time limit.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:runstarts Redis fromcompose.yaml; a POST cachesresourceapi::sdlc-systems::2Generated by Claude Code