Skip to content

feat: Add Redis cache adapter - #222

Open
vazarkevych wants to merge 20 commits into
mainfrom
feat/add-redis-cache-adapter
Open

vazarkevych wants to merge 20 commits into
mainfrom
feat/add-redis-cache-adapter

Conversation

@vazarkevych

Copy link
Copy Markdown
Collaborator

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's GbCacheManager. 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:

  • Jedis — JedisGbCacheManager
  • Lettuce — LettuceGbCacheManager

Both 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

AbstractRedisGbCacheManager contains the shared logic:

  • key namespacing
  • Redis hash ↔ entry mapping
  • cache-miss vs failure handling
  • FeatureCacheException wrapping

Each adapter only implements low-level Redis operations:

  • writeHash
  • readHash
  • deleteByPrefix

Storage format

Each cache entry is stored as a Redis hash with:

  • data
  • updatedAt (epoch millis)

RedisCacheEntry centralizes field names and mapping so both adapters use the same on-the-wire format.

getLastUpdatedMillis() is backed by updatedAt, enabling freshness-based cache refresh skipping in the SDK.


Configuration (self-typed builder)

AbstractRedisCacheOptions provides configuration:

  • keyPrefix
    Namespace for all keys (default: growthbook:features:).
    clearCache() removes only keys under this prefix via SCAN + DEL (no blocking KEYS).

  • ttl
    Optional per-entry TTL applied on write. null means no expiration.

  • clock
    Injectable clock for deterministic testing.


Connection ownership

The caller owns the lifecycle of:

  • JedisPool
  • StatefulRedisConnection

The adapter only borrows connections and executes commands.


Error handling

  • Missing key → treated as cache miss (null)
  • Real Redis failures → wrapped in FeatureCacheException with the affected key for context

Usage

Jedis

GbCacheManager cache = JedisGbCacheManager.builder()
        .jedisPool(jedisPool)
        .keyPrefix("growthbook:features:")
        .ttl(Duration.ofMinutes(10))
        .buildManager();

Lettuce

GbCacheManager cache = LettuceGbCacheManager.builder()
        .connection(connection)
        .buildManager();

Testing

Unit tests

  • JedisGbCacheManagerTest
  • LettuceGbCacheManagerTest
  • Implemented with Mockito

Integration tests

  • RedisCacheIntegrationTest
  • Runs against real Redis via Testcontainers
  • Located in a separate integrationTest source set
  • Exposed via opt-in integrationTest Gradle task
  • Not included in check, so standard builds do not require Docker

Build / packaging

  • New module registered in settings.gradle
  • Targets Java 8 (sourceCompatibility / targetCompatibility = 1.8)
  • Publishes sources and Javadoc jars to GitHub Packages
  • Fully consistent with existing lib module

Volodymyr Nazarkevych added 3 commits July 3, 2026 16:26
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 madhuchavva left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@andywhite37

Copy link
Copy Markdown
Contributor

@greptile review

@greptile-apps

greptile-apps Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium impact] No new blocking issue was found in the changes since the last review.

Reviews (6) · Last reviewed commit: "fix: treat a malformed cache timestamp a..." · Reviewed by Greptile

@andywhite37 andywhite37 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:test tests and 29 related :lib repository 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 two GBFeaturesRepository instances 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.

Comment thread growthbook-cache-redis/build.gradle Outdated
Volodymyr Nazarkevych added 3 commits October 5, 2026 13:21
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
@andywhite37

Copy link
Copy Markdown
Contributor

@greptile review

Comment thread lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java Outdated
Volodymyr Nazarkevych added 4 commits October 9, 2026 14:54
# 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.
@vazarkevych

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment thread growthbook-cache-redis/build.gradle
Comment thread lib/src/test/java/growthbook/sdk/java/GBFeaturesRepositoryTest.java Outdated
Comment thread lib/src/test/java/growthbook/sdk/java/GBFeaturesRepositoryTest.java Outdated
Comment thread lib/src/test/java/growthbook/sdk/java/GBFeaturesRepositoryTest.java Outdated
Volodymyr Nazarkevych added 3 commits October 9, 2026 18:40
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.
@vazarkevych

Copy link
Copy Markdown
Collaborator Author

@greptile review

@andywhite37 andywhite37 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/src/main/java/growthbook/sdk/java/repository/GBFeaturesRepository.java Outdated
@andywhite37

Copy link
Copy Markdown
Contributor

@greptile review

Volodymyr Nazarkevych added 4 commits October 11, 2026 17:24
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).
@vazarkevych

Copy link
Copy Markdown
Collaborator Author

@greptile review

This branch has not been deployed

No deployments
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.

3 participants