Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,38 @@ The SDK supports multiple cache modes for persisting feature payloads:

You can configure these through `Options` when using `GrowthBookClient`, or via the repository builder’s `cacheManager` directly. When cache is disabled (`isCacheDisabled=true`), the repository won’t attempt any persistence.

### Caffeine cache adapter

The optional `growthbook-cache-caffeine` module provides a Caffeine-backed `GbCacheManager` for applications that want a production-ready in-process cache without using filesystem persistence.

```groovy
dependencies {
implementation 'com.github.growthbook:growthbook-sdk-java:<version>'
implementation 'com.github.growthbook:growthbook-cache-caffeine:<version>'
}
```

```java
import growthbook.sdk.java.cache.caffeine.CaffeineGbCacheManager;
import growthbook.sdk.java.multiusermode.GrowthBookClient;
import growthbook.sdk.java.multiusermode.configurations.Options;
import growthbook.sdk.java.sandbox.CacheMode;

import java.time.Duration;

Options options = Options.builder()
.apiHost("https://cdn.growthbook.io")
.clientKey("sdk-abc123")
.cacheMode(CacheMode.CUSTOM)
.cacheManager(CaffeineGbCacheManager.builder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(30))
.buildManager())
.build();

GrowthBookClient gb = new GrowthBookClient(options);
```

### STALE_WHILE_REVALIDATE (default)

With the SWR strategy, the repository will:
Expand Down
71 changes: 71 additions & 0 deletions growthbook-cache-caffeine/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
plugins {
id 'java-library'
id 'maven-publish'
}

sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8

repositories {
mavenCentral()
}

dependencies {
api project(':lib')

implementation 'com.github.ben-manes.caffeine:caffeine:2.9.3'

testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2'
}

java {
withSourcesJar()
withJavadocJar()
}

tasks.named('test') {
useJUnitPlatform()
}

publishing {
publications {
mavenJava(MavenPublication) {
from components.java

pom {
name = 'GrowthBook SDK Java Caffeine Cache Adapter'
description = 'Optional Caffeine cache adapter for the GrowthBook Java SDK'
url = 'https://github.com/growthbook/growthbook-sdk-java'
licenses {
license {
name = 'MIT License'
url = 'https://github.com/growthbook/growthbook-sdk-java/blob/main/LICENSE'
}
}
developers {
developer {
id = 'growthbook'
name = 'GrowthBook'
email = 'hello@growthbook.io'
}
}
scm {
connection = 'scm:git:git://github.com/growthbook/growthbook-sdk-java.git'
developerConnection = 'scm:git:ssh://github.com:growthbook/growthbook-sdk-java.git'
url = 'https://github.com/growthbook/growthbook-sdk-java'
}
}
}
}

repositories {
maven {
name = "GitHubPackages"
url = uri("https://maven.pkg.github.com/growthbook/growthbook-sdk-java")
credentials {
username = System.getenv("GITHUB_ACTOR")
password = System.getenv("GITHUB_TOKEN")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package growthbook.sdk.java.cache.caffeine;

final class CaffeineCacheEntry {

private final String data;
private final long lastUpdatedMillis;

CaffeineCacheEntry(String data, long lastUpdatedMillis) {
this.data = data;
this.lastUpdatedMillis = lastUpdatedMillis;
}

String getData() {
return data;
}

long getLastUpdatedMillis() {
return lastUpdatedMillis;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package growthbook.sdk.java.cache.caffeine;

import com.github.benmanes.caffeine.cache.Ticker;

import java.time.Clock;
import java.time.Duration;
import java.util.Objects;

/**
* Configuration for {@link CaffeineGbCacheManager}.
*/
public final class CaffeineCacheOptions {

public static final long DEFAULT_MAXIMUM_SIZE = 1000L;

private final Clock clock;
private final Ticker ticker;
private final long maximumSize;
private final Long maximumWeight;
private final boolean recordStats;
private final Duration expireAfterWrite;
private final Duration expireAfterAccess;

private CaffeineCacheOptions(Builder builder) {
this.maximumSize = builder.maximumSize;
this.maximumWeight = builder.maximumWeight;
this.expireAfterWrite = builder.expireAfterWrite;
this.expireAfterAccess = builder.expireAfterAccess;
this.recordStats = builder.recordStats;
this.clock = builder.clock;
this.ticker = builder.ticker;
}

public static CaffeineCacheOptions defaults() {
return builder().build();
}

public static Builder builder() {
return new Builder();
}

public long getMaximumSize() {
return maximumSize;
}

/**
* @return the maximum total payload weight (in bytes) before size-based eviction, or
* {@code null} when entry-count bounding ({@link #getMaximumSize()}) is used instead
*/
public Long getMaximumWeight() {
return maximumWeight;
}

public Duration getExpireAfterWrite() {
return expireAfterWrite;
}

public Duration getExpireAfterAccess() {
return expireAfterAccess;
}

public boolean isRecordStats() {
return recordStats;
}

public Clock getClock() {
return clock;
}

public Ticker getTicker() {
return ticker;
}

public static final class Builder {
private long maximumSize = DEFAULT_MAXIMUM_SIZE;
private Long maximumWeight;
private Duration expireAfterWrite;
private Duration expireAfterAccess;
private boolean recordStats;
private Clock clock = Clock.systemUTC();
private Ticker ticker;

private Builder() {
}

/**
* Bounds the cache by entry count. Ignored when {@link #maximumWeight(long)} is set, which
* takes precedence.
*/
public Builder maximumSize(long maximumSize) {
if (maximumSize <= 0) {
throw new IllegalArgumentException("maximumSize must be greater than 0");
}
this.maximumSize = maximumSize;
return this;
}

/**
* Bounds the cache by total payload weight in bytes (UTF-8 length of cached values).
*
* <p>This is the recommended bound for the GrowthBook feature cache: it stores a single
* large JSON payload per endpoint, so entry-count limits via {@link #maximumSize(long)} do
* not meaningfully constrain memory. When set, {@code maximumWeight} takes precedence over
* {@code maximumSize}.
*/
public Builder maximumWeight(long maximumWeight) {
if (maximumWeight <= 0) {
throw new IllegalArgumentException("maximumWeight must be greater than 0");
}
this.maximumWeight = maximumWeight;
return this;
}

public Builder expireAfterWrite(Duration expireAfterWrite) {
if (expireAfterWrite != null && (expireAfterWrite.isZero() || expireAfterWrite.isNegative())) {
throw new IllegalArgumentException("expireAfterWrite must be greater than 0");
}
this.expireAfterWrite = expireAfterWrite;
return this;
}

/**
* Evicts an entry when it has not been accessed for the given duration.
*
* <p>Note: cache reads count as accesses. Because the SDK reads through
* {@code loadCache} and {@code getLastUpdatedMillis} (both lookups), each read resets this
* timer — an entry expires only after it has been idle (neither written nor read) for the
* configured duration.
*/
public Builder expireAfterAccess(Duration expireAfterAccess) {
if (expireAfterAccess != null && (expireAfterAccess.isZero() || expireAfterAccess.isNegative())) {
throw new IllegalArgumentException("expireAfterAccess must be greater than 0");
}
this.expireAfterAccess = expireAfterAccess;
return this;
}

public Builder recordStats(boolean recordStats) {
this.recordStats = recordStats;
return this;
}

public Builder clock(Clock clock) {
this.clock = Objects.requireNonNull(clock, "clock");
return this;
}

public Builder ticker(Ticker ticker) {
this.ticker = Objects.requireNonNull(ticker, "ticker");
return this;
}

public CaffeineCacheOptions build() {
return new CaffeineCacheOptions(this);
}

/**
* Convenience terminal that builds these options into a ready-to-use cache manager.
*
* @return a {@link CaffeineGbCacheManager} configured with these options
*/
public CaffeineGbCacheManager buildManager() {
return CaffeineGbCacheManager.create(build());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package growthbook.sdk.java.cache.caffeine;

/**
* Immutable snapshot of {@link CaffeineGbCacheManager} cache statistics.
*
* <p>Values are meaningful only when statistics recording is enabled via
* {@link CaffeineCacheOptions.Builder#recordStats(boolean)}; otherwise every counter is {@code 0}.
* This type intentionally does not expose the underlying Caffeine {@code CacheStats}, so callers
* (for example the SDK diagnostics layer) can read cache health without a Caffeine dependency.
*/
public final class CaffeineCacheStats {

private final long hitCount;
private final long missCount;
private final boolean recording;
private final long evictionCount;

CaffeineCacheStats(boolean recording, long hitCount, long missCount, long evictionCount) {
this.recording = recording;
this.hitCount = hitCount;
this.missCount = missCount;
this.evictionCount = evictionCount;
}

/**
* @return whether statistics recording is enabled; when {@code false} all counters are {@code 0}
*/
public boolean isRecording() {
return recording;
}

/**
* @return number of cache lookups that returned a stored value
*/
public long getHitCount() {
return hitCount;
}

/**
* @return number of cache lookups that did not return a stored value
*/
public long getMissCount() {
return missCount;
}

/**
* @return number of cache lookups recorded ({@code hitCount + missCount})
*/
public long getRequestCount() {
return hitCount + missCount;
}

/**
* @return ratio of hits to lookups in {@code [0.0, 1.0]}, or {@code 1.0} when no lookups occurred
*/
public double getHitRate() {
long requestCount = getRequestCount();
return requestCount == 0 ? 1.0 : (double) hitCount / requestCount;
}

/**
* @return number of entries evicted (by size or expiry)
*/
public long getEvictionCount() {
return evictionCount;
}

@Override
public String toString() {
return "CaffeineCacheStats{"
+ "recording=" + recording
+ ", hitCount=" + hitCount
+ ", missCount=" + missCount
+ ", evictionCount=" + evictionCount
+ '}';
}
}
Loading
Loading