Repository navigation
feat: Add Redis cache adapter - #222
vazarkevych wants to merge 20 commits into
Conversation
Adds an optional growthbook-cache-redis Gradle module providing Redis-backed GbCacheManager implementations: - AbstractRedisGbCacheManager with shared key-prefixing, TTL and last-updated timestamp handling. - JedisGbCacheManager (Jedis pool) and LettuceGbCacheManager (Lettuce connection) adapters; both clients are compileOnly so consumers add only the one they use. - Unit tests with mocked clients plus opt-in Testcontainers integration tests (separate integrationTest task, requires Docker).
- getLastUpdatedMillis now reads only the updatedAt hash field (HGET) instead of fetching the whole payload with HGETALL, so freshness checks no longer transfer the full feature JSON. - Document that Redis Cluster is not supported (multi-key DEL fails with CROSSSLOT, SCAN covers a single node) and suggest a hash-tag key prefix as a workaround. - GBFeaturesRepository.shutdown() no longer clears the cache store: persisted features are meant to survive restarts, and with shared stores (e.g. Redis) clearing on shutdown would wipe the cache for every other SDK instance sharing the namespace. - Fix stale POM description (adapter supports both Jedis and Lettuce).
madhuchavva
left a comment
There was a problem hiding this comment.
Requesting changes for the Redis TTL precision issue. The module tests pass, but accepting positive sub-second Duration values and truncating with getSeconds() can immediately expire/delete Redis entries. Please fix the TTL handling and add a regression test.
…adapter # Conflicts: # settings.gradle
|
@greptile review |
|
andywhite37
left a comment
There was a problem hiding this comment.
The module mirrors the Caffeine and JCache adapters, and both clients round-trip correctly against a real Redis. The PEXPIRE switch and sub-second tests appear to address the earlier TTL change request.
Main concern: with the default key prefix, separate SDK instances that share a Redis appear to read each other's features (Greptile thread, now reproduced). Custom prefixes with glob characters also break clearCache() (also confirmed). The other comments are Low.
Testing:
- The 22
:growthbook-cache-redis:testtests and 29 related:librepository tests pass locally on JDK 17. CI is green, including the Java 8 job. - Ad hoc tests ran both adapters against Redis 7 in Docker: round-trip, TTL,
clearCache, glob prefix, and twoGBFeaturesRepositoryinstances with different client keys sharing one Redis. - Not tested: Sentinel and Cluster. The PR's integration tests aren't in the diff (see inline).
- No manual testing suggested: this is a library change, and the ad hoc real-Redis runs cover the changed behavior.
Addresses review findings on the Redis cache adapter: - Default cache keys collide (P1): GBFeaturesRepository always used the fixed key FEATURE_CACHE.json, so two instances with different client keys sharing a cache store (e.g. Redis) could read or skip-refresh off each other's features. Derive a per-feature-set key from the features endpoint (apiHost + clientKey) via a short SHA-256 hash; it stays filesystem-safe for the default file cache. - clearCache crosses namespaces (P1): a keyPrefix containing Redis glob metacharacters was used literally for writes but as a SCAN MATCH pattern on delete, so user[123]:features: could delete unrelated keys. Escape \ * ? [ ] before building the match pattern. - Failed expiry leaves permanent entries (P1): HSET and PEXPIRE ran as separate commands; a failed PEXPIRE left an entry without its TTL. Apply both atomically (Jedis MULTI/EXEC transaction, Lettuce MULTI/EXEC). - Sub-millisecond TTL deletes entries (P2): a positive TTL under 1ms rounded to 0 and PEXPIRE 0 deleted the entry. Reject TTLs below 1ms. Tests: per-tenant isolation leaves existing cache tests green (they use anyString keys); add shutdown_doesNotClearSharedCacheStore; add Jedis MULTI/EXEC and glob-escape coverage; assert sub-ms TTL rejection for both adapters. Switch the redis module test dependency to mockito-inline (to match lib) so the Jedis transaction can be verified.
The integrationTest source set, task, and Testcontainers dependencies were wired up but no src/integrationTest tests exist, so the task had nothing to run. Remove the unused scaffolding; the real-Redis behavior can be covered by a dedicated integration-test PR when added.
# Conflicts: # lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java
# Conflicts: # lib/src/test/java/growthbook/sdk/java/GBFeaturesRepositoryTest.java
|
@greptile review |
# Conflicts: # lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java
…red-connection transaction LettuceGbCacheManager issued MULTI/HSET/PEXPIRE/EXEC over a single shared connection. MULTI/EXEC is connection state, so concurrent refreshes could interleave their queued commands or hit transaction errors, and an exception between MULTI and EXEC left the transaction open, poisoning later reads and writes on that connection. Replace the transaction with a Lua script run through EVAL: HSET and PEXPIRE are applied atomically in one multiplexed command that carries no client-side transaction state, so concurrent writes stay isolated and a failure cannot leave the connection unusable.
…point key change Per-endpoint cache keys hash the features endpoint into FEATURE_CACHE_<hash>.json, so entries written by an older SDK under the shared FEATURE_CACHE.json look missing after an upgrade. If the first fetch then fails, the repository cannot fall back to the still-valid cached features. On a cache read, try the per-endpoint key first (preserving tenant isolation) and fall back to the legacy FILE_NAME key on a miss. The fallback is transitional: the first successful fetch repopulates the per-endpoint key.
The merged lifecycle changes made sseHttpClient a final AtomicReference, so the shutdown tests can no longer reflectively assign a raw OkHttpClient to the field. Mutate the existing reference instead.
|
@greptile review |
When ttl is null the adapters wrote the hash with HSET but left any expiry a previous timed write had set, so switching from a timed cache to the default settings let newly saved features disappear at the old deadline despite ttl(null) promising no expiration. Issue PERSIST as part of the no-TTL write in both adapters: Jedis now runs HSET+PERSIST in one MULTI/EXEC, and Lettuce issues PERSIST after HSET on its connection.
Add the guarded JavaCompile configuration used by the other cache modules so a Java 9+ compiler sets options.release = 8, preventing post-Java-8 APIs from slipping into the Java 8 artifact.
Replace the real http://localhost initialize() in shutdown_withSseStrategy with an injected SSE client, so the test no longer depends on a local server or a catch-and-ignored connection failure, and drop the inline comments that restated the surrounding statements.
|
@greptile review |
andywhite37
left a comment
There was a problem hiding this comment.
Re-reviewed the changes and affected cache callers. Cache isolation, timestamp selection, and concurrent Lettuce expiry handling still need attention.
All 49 targeted tests pass. Redis/WireMock checks reproduced the findings below; CI is green. The earlier full-suite run passed 628 tests with 4 skipped. No manual testing suggested because the affected paths were exercised directly; Sentinel remains untested.
|
@greptile review |
The no-TTL path ran HSET then PERSIST as two separate commands. During a rollout where a timed and an untimed instance write the same key, the sequence untimed HSET -> timed HSET+PEXPIRE -> untimed PERSIST strips the timed entry's expiry, leaving it cached forever. Apply HSET+PERSIST in one Lua script (EVAL), matching the timed branch's HSET+PEXPIRE script, so no separate PERSIST can remove another writer's expiry.
…ccess A queued command failing inside MULTI/EXEC (e.g. WRONGTYPE) is returned as an error element in the EXEC result list rather than thrown, so writeHash completed "successfully" without saving. Inspect the EXEC results and rethrow the first command error (and treat a null EXEC as an aborted transaction); AbstractRedisGbCacheManager wraps it in FeatureCacheException.
readCacheLastUpdatedMillis fell back to the legacy FILE_NAME timestamp when the per-endpoint key had none, so a per-endpoint payload of unknown freshness could borrow the legacy entry's timestamp and wrongly skip the network refresh. Read the timestamp only from the per-endpoint key and preserve unknown freshness (null forces a fetch). The legacy fallback remains on the payload read, which is reached only after a fetch has already failed.
getLastUpdatedMillis parsed a non-numeric updatedAt field to 0 (epoch), which reads as an ancient-but-known timestamp rather than unknown. Per the GbCacheManager contract, an unparseable write time is unknown and must return null so the SDK refreshes over the network. Add RedisCacheEntry.parseTimestampOrNull and use it, and document that clearCache() removes every entry under the shared key prefix (all instances sharing one adapter).
|
@greptile review |
Add Redis cache adapter (growthbook-cache-redis)
Summary
Adds a new optional Gradle module,
growthbook-cache-redis, that provides a Redis-backed implementation of the SDK'sGbCacheManager. This enables sharing the GrowthBook feature cache across multiple instances/pods instead of keeping it in-process or on the local filesystem.Two Redis clients are supported out of the box:
JedisGbCacheManagerLettuceGbCacheManagerBoth client dependencies are
compileOnly, so consumers include only the client they actually use; nothing is forced onto the SDK's runtime classpath.Design
Shared base, thin adapters
AbstractRedisGbCacheManagercontains the shared logic:FeatureCacheExceptionwrappingEach adapter only implements low-level Redis operations:
writeHashreadHashdeleteByPrefixStorage format
Each cache entry is stored as a Redis hash with:
dataupdatedAt(epoch millis)RedisCacheEntrycentralizes field names and mapping so both adapters use the same on-the-wire format.getLastUpdatedMillis()is backed byupdatedAt, enabling freshness-based cache refresh skipping in the SDK.Configuration (self-typed builder)
AbstractRedisCacheOptionsprovides configuration:keyPrefix
Namespace for all keys (default:
growthbook:features:).clearCache()removes only keys under this prefix viaSCAN+DEL(no blockingKEYS).ttl
Optional per-entry TTL applied on write.
nullmeans no expiration.clock
Injectable clock for deterministic testing.
Connection ownership
The caller owns the lifecycle of:
JedisPoolStatefulRedisConnectionThe adapter only borrows connections and executes commands.
Error handling
null)FeatureCacheExceptionwith the affected key for contextUsage
Jedis
Lettuce
Testing
Unit tests
Integration tests
integrationTestsource setintegrationTestGradle taskcheck, so standard builds do not require DockerBuild / packaging
settings.gradlesourceCompatibility/targetCompatibility = 1.8)libmodule