Skip to content

Commit

Permalink
feat: assignment cache (#50)
Browse files Browse the repository at this point in the history
* feat: assignment cache
* bandit assignment cache
* LRU assignment cache
* Expiring cache
* version bump for release
  • Loading branch information
typotter authored Oct 17, 2024
1 parent 93b5003 commit f58cfec
Show file tree
Hide file tree
Showing 16 changed files with 609 additions and 71 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ or [JVM](https://github.com/Eppo-exp/java-server-sdk) SDKs.

```groovy
dependencies {
implementation 'cloud.eppo:sdk-common-jvm:3.3.2'
implementation 'cloud.eppo:sdk-common-jvm:3.4.0'
}
```

Expand Down
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ plugins {
}

group = 'cloud.eppo'
version = '3.3.5-SNAPSHOT'
version = '3.4.0'
ext.isReleaseVersion = !version.endsWith("SNAPSHOT")

java {
Expand All @@ -21,6 +21,9 @@ dependencies {
implementation 'com.github.zafarkhaja:java-semver:0.10.2'
implementation "com.squareup.okhttp3:okhttp:4.12.0"

// For LRU and expiring maps
implementation group: 'org.apache.commons', name: 'commons-collections4', version: '4.4'

// For UFC DTOs
implementation 'commons-codec:commons-codec:1.17.1'
implementation 'org.slf4j:slf4j-api:2.0.16'
Expand Down
95 changes: 70 additions & 25 deletions src/main/java/cloud/eppo/BaseEppoClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static cloud.eppo.Utils.throwIfEmptyOrNull;

import cloud.eppo.api.*;
import cloud.eppo.cache.AssignmentCacheEntry;
import cloud.eppo.logging.Assignment;
import cloud.eppo.logging.AssignmentLogger;
import cloud.eppo.logging.BanditAssignment;
Expand Down Expand Up @@ -35,6 +36,8 @@ public class BaseEppoClient {
private final String sdkName;
private final String sdkVersion;
private boolean isGracefulMode;
private final IAssignmentCache assignmentCache;
private final IAssignmentCache banditAssignmentCache;

@Nullable protected CompletableFuture<Boolean> getInitialConfigFuture() {
return initialConfigFuture;
Expand All @@ -47,6 +50,9 @@ public class BaseEppoClient {
/** @noinspection FieldMayBeFinal */
private static EppoHttpClient httpClientOverride = null;

// It is important that the bandit assignment cache expire with a short-enough TTL to last about
// one user session.
// The recommended is 10 minutes (per @Sven)
protected BaseEppoClient(
@NotNull String apiKey,
@NotNull String sdkName,
Expand All @@ -58,7 +64,9 @@ protected BaseEppoClient(
boolean isGracefulMode,
boolean expectObfuscatedConfig,
boolean supportBandits,
@Nullable CompletableFuture<Configuration> initialConfiguration) {
@Nullable CompletableFuture<Configuration> initialConfiguration,
@Nullable IAssignmentCache assignmentCache,
@Nullable IAssignmentCache banditAssignmentCache) {

if (apiKey == null) {
throw new IllegalArgumentException("Unable to initialize Eppo SDK due to missing API key");
Expand All @@ -71,6 +79,9 @@ protected BaseEppoClient(
host = DEFAULT_HOST;
}

this.assignmentCache = assignmentCache;
this.banditAssignmentCache = banditAssignmentCache;

EppoHttpClient httpClient = buildHttpClient(host, apiKey, sdkName, sdkVersion);
this.configurationStore =
configurationStore != null ? configurationStore : new ConfigurationStore();
Expand Down Expand Up @@ -156,33 +167,51 @@ protected EppoValue getTypedAssignment(
}

if (assignedValue != null && assignmentLogger != null && evaluationResult.doLog()) {
String allocationKey = evaluationResult.getAllocationKey();
String experimentKey =
flagKey
+ '-'
+ allocationKey; // Our experiment key is derived by hyphenating the flag key and
// allocation key
String variationKey = evaluationResult.getVariation().getKey();
Map<String, String> extraLogging = evaluationResult.getExtraLogging();
Map<String, String> metaData = buildLogMetaData(config.isConfigObfuscated());

Assignment assignment =
new Assignment(
experimentKey,
flagKey,
allocationKey,
variationKey,
subjectKey,
subjectAttributes,
extraLogging,
metaData);

try {
assignmentLogger.logAssignment(assignment);
String allocationKey = evaluationResult.getAllocationKey();
String experimentKey =
flagKey
+ '-'
+ allocationKey; // Our experiment key is derived by hyphenating the flag key and
// allocation key
String variationKey = evaluationResult.getVariation().getKey();
Map<String, String> extraLogging = evaluationResult.getExtraLogging();
Map<String, String> metaData = buildLogMetaData(config.isConfigObfuscated());

Assignment assignment =
new Assignment(
experimentKey,
flagKey,
allocationKey,
variationKey,
subjectKey,
subjectAttributes,
extraLogging,
metaData);

// Deduplication of assignment logging is possible by providing an `IAssignmentCache`.
// Default to true, only avoid logging if there's a cache hit.
boolean logAssignment = true;
AssignmentCacheEntry cacheEntry = AssignmentCacheEntry.fromVariationAssignment(assignment);
if (assignmentCache != null) {
if (assignmentCache.hasEntry(cacheEntry)) {
logAssignment = false;
}
}

if (logAssignment) {
assignmentLogger.logAssignment(assignment);

if (assignmentCache != null) {
assignmentCache.put(cacheEntry);
}
}

} catch (Exception e) {
log.warn("Error logging assignment: {}", e.getMessage(), e);
log.error("Error logging assignment: {}", e.getMessage(), e);
}
}

return assignedValue != null ? assignedValue : defaultValue;
}

Expand Down Expand Up @@ -428,7 +457,23 @@ public BanditResult getBanditAction(
banditResult.getActionAttributes().getCategoricalAttributes(),
buildLogMetaData(config.isConfigObfuscated()));

banditLogger.logBanditAssignment(banditAssignment);
// Log, only if there is no cache hit.
boolean logBanditAssignment = true;
AssignmentCacheEntry cacheEntry =
AssignmentCacheEntry.fromBanditAssignment(banditAssignment);
if (banditAssignmentCache != null) {
if (banditAssignmentCache.hasEntry(cacheEntry)) {
logBanditAssignment = false;
}
}

if (logBanditAssignment) {
banditLogger.logBanditAssignment(banditAssignment);

if (banditAssignmentCache != null) {
banditAssignmentCache.put(cacheEntry);
}
}
} catch (Exception e) {
log.warn("Error logging bandit assignment: {}", e.getMessage(), e);
}
Expand Down
32 changes: 32 additions & 0 deletions src/main/java/cloud/eppo/api/AbstractAssignmentCache.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package cloud.eppo.api;

import cloud.eppo.cache.AssignmentCacheEntry;
import cloud.eppo.cache.AssignmentCacheKey;
import java.util.Map;

/**
* {@link IAssignmentCache} implementation which takes a map to use as the underlying storage
* mechanism.
*/
public abstract class AbstractAssignmentCache implements IAssignmentCache {
protected final Map<String, String> delegate;

protected AbstractAssignmentCache(final Map<String, String> delegate) {
this.delegate = delegate;
}

@Override
public boolean hasEntry(AssignmentCacheEntry entry) {
String serializedEntry = get(entry.getKey());
return serializedEntry != null && serializedEntry.equals(entry.getValueKeyString());
}

private String get(AssignmentCacheKey key) {
return delegate.get(key.toString());
}

@Override
public void put(AssignmentCacheEntry entry) {
delegate.put(entry.getKeyString(), entry.getValueKeyString());
}
}
18 changes: 18 additions & 0 deletions src/main/java/cloud/eppo/api/IAssignmentCache.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package cloud.eppo.api;

import cloud.eppo.cache.AssignmentCacheEntry;

/**
* A cache capable of storing the key components of assignments (both variation and bandit) to
* determine both presence and uniqueness of the cached value.
*/
public interface IAssignmentCache {
void put(AssignmentCacheEntry entry);

/**
* Determines whether the entry is present. Implementations must first check for presence by using
* the `{@link AssignmentCacheEntry}.getKey()` method and then whether the cached value matches by
* comparing the `getValueKeyString()` method results.
*/
boolean hasEntry(AssignmentCacheEntry entry);
}
59 changes: 59 additions & 0 deletions src/main/java/cloud/eppo/cache/AssignmentCacheEntry.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package cloud.eppo.cache;

import cloud.eppo.logging.Assignment;
import cloud.eppo.logging.BanditAssignment;
import java.util.Objects;
import org.jetbrains.annotations.NotNull;

public class AssignmentCacheEntry {
private final AssignmentCacheKey key;
private final AssignmentCacheValue value;

public AssignmentCacheEntry(
@NotNull AssignmentCacheKey key, @NotNull AssignmentCacheValue value) {
this.key = key;
this.value = value;
}

public static AssignmentCacheEntry fromVariationAssignment(Assignment assignment) {
return new AssignmentCacheEntry(
new AssignmentCacheKey(assignment.getSubject(), assignment.getFeatureFlag()),
new VariationCacheValue(assignment.getAllocation(), assignment.getVariation()));
}

public static AssignmentCacheEntry fromBanditAssignment(BanditAssignment assignment) {
return new AssignmentCacheEntry(
new AssignmentCacheKey(assignment.getSubject(), assignment.getFeatureFlag()),
new BanditCacheValue(assignment.getBandit(), assignment.getAction()));
}

@NotNull public AssignmentCacheKey getKey() {
return key;
}

@NotNull public String getKeyString() {
return key.toString();
}

@NotNull public String getValueKeyString() {
return value.getValueIdentifier();
}

@NotNull public AssignmentCacheValue getValue() {
return value;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
AssignmentCacheEntry that = (AssignmentCacheEntry) o;
return Objects.equals(key, that.key)
&& Objects.equals(value.getValueIdentifier(), that.value.getValueIdentifier());
}

@Override
public int hashCode() {
return Objects.hash(key, value);
}
}
45 changes: 45 additions & 0 deletions src/main/java/cloud/eppo/cache/AssignmentCacheKey.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package cloud.eppo.cache;

import java.util.Objects;

/**
* Assignment cache keys are only on the subject and flag level, while a combination of keys and
* fields are used for uniqueness checking. This way, if an assigned variation or bandit action
* changes for a flag, it evicts the old one. Then, if an older assignment is later reassigned, it
* will be treated as new.
*/
public class AssignmentCacheKey {
private final String subjectKey;
private final String flagKey;

public AssignmentCacheKey(String subjectKey, String flagKey) {
this.subjectKey = subjectKey;
this.flagKey = flagKey;
}

public String getSubjectKey() {
return subjectKey;
}

public String getFlagKey() {
return flagKey;
}

@Override
public String toString() {
return subjectKey + ";" + flagKey;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
AssignmentCacheKey that = (AssignmentCacheKey) o;
return Objects.equals(toString(), that.toString());
}

@Override
public int hashCode() {
return Objects.hash(subjectKey, flagKey);
}
}
7 changes: 7 additions & 0 deletions src/main/java/cloud/eppo/cache/AssignmentCacheValue.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package cloud.eppo.cache;

import java.io.Serializable;

public interface AssignmentCacheValue extends Serializable {
String getValueIdentifier();
}
31 changes: 31 additions & 0 deletions src/main/java/cloud/eppo/cache/BanditCacheValue.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package cloud.eppo.cache;

import java.util.Objects;

public class BanditCacheValue implements AssignmentCacheValue {
private final String banditKey;
private final String actionKey;

public BanditCacheValue(String banditKey, String actionKey) {
this.banditKey = banditKey;
this.actionKey = actionKey;
}

@Override
public String getValueIdentifier() {
return banditKey + ";" + actionKey;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
BanditCacheValue that = (BanditCacheValue) o;
return Objects.equals(banditKey, that.banditKey) && Objects.equals(actionKey, that.actionKey);
}

@Override
public int hashCode() {
return Objects.hash(banditKey, actionKey);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package cloud.eppo.cache;

import cloud.eppo.api.AbstractAssignmentCache;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.apache.commons.collections4.map.PassiveExpiringMap;

public class ExpiringInMemoryAssignmentCache extends AbstractAssignmentCache {
public ExpiringInMemoryAssignmentCache(int cacheTimeout, TimeUnit timeUnit) {
super(Collections.synchronizedMap(new PassiveExpiringMap<>(cacheTimeout, timeUnit)));
}

public ExpiringInMemoryAssignmentCache(
Map<String, String> delegate, int cacheTimeout, TimeUnit timeUnit) {
super(Collections.synchronizedMap(new PassiveExpiringMap<>(cacheTimeout, timeUnit, delegate)));
}
}
Loading

0 comments on commit f58cfec

Please sign in to comment.