Skip to content

Commit f58cfec

Browse files
authored
feat: assignment cache (#50)
* feat: assignment cache * bandit assignment cache * LRU assignment cache * Expiring cache * version bump for release
1 parent 93b5003 commit f58cfec

16 files changed

+609
-71
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ or [JVM](https://github.com/Eppo-exp/java-server-sdk) SDKs.
1010

1111
```groovy
1212
dependencies {
13-
implementation 'cloud.eppo:sdk-common-jvm:3.3.2'
13+
implementation 'cloud.eppo:sdk-common-jvm:3.4.0'
1414
}
1515
```
1616

build.gradle

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ plugins {
66
}
77

88
group = 'cloud.eppo'
9-
version = '3.3.5-SNAPSHOT'
9+
version = '3.4.0'
1010
ext.isReleaseVersion = !version.endsWith("SNAPSHOT")
1111

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

24+
// For LRU and expiring maps
25+
implementation group: 'org.apache.commons', name: 'commons-collections4', version: '4.4'
26+
2427
// For UFC DTOs
2528
implementation 'commons-codec:commons-codec:1.17.1'
2629
implementation 'org.slf4j:slf4j-api:2.0.16'

src/main/java/cloud/eppo/BaseEppoClient.java

Lines changed: 70 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import static cloud.eppo.Utils.throwIfEmptyOrNull;
44

55
import cloud.eppo.api.*;
6+
import cloud.eppo.cache.AssignmentCacheEntry;
67
import cloud.eppo.logging.Assignment;
78
import cloud.eppo.logging.AssignmentLogger;
89
import cloud.eppo.logging.BanditAssignment;
@@ -35,6 +36,8 @@ public class BaseEppoClient {
3536
private final String sdkName;
3637
private final String sdkVersion;
3738
private boolean isGracefulMode;
39+
private final IAssignmentCache assignmentCache;
40+
private final IAssignmentCache banditAssignmentCache;
3841

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

53+
// It is important that the bandit assignment cache expire with a short-enough TTL to last about
54+
// one user session.
55+
// The recommended is 10 minutes (per @Sven)
5056
protected BaseEppoClient(
5157
@NotNull String apiKey,
5258
@NotNull String sdkName,
@@ -58,7 +64,9 @@ protected BaseEppoClient(
5864
boolean isGracefulMode,
5965
boolean expectObfuscatedConfig,
6066
boolean supportBandits,
61-
@Nullable CompletableFuture<Configuration> initialConfiguration) {
67+
@Nullable CompletableFuture<Configuration> initialConfiguration,
68+
@Nullable IAssignmentCache assignmentCache,
69+
@Nullable IAssignmentCache banditAssignmentCache) {
6270

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

82+
this.assignmentCache = assignmentCache;
83+
this.banditAssignmentCache = banditAssignmentCache;
84+
7485
EppoHttpClient httpClient = buildHttpClient(host, apiKey, sdkName, sdkVersion);
7586
this.configurationStore =
7687
configurationStore != null ? configurationStore : new ConfigurationStore();
@@ -156,33 +167,51 @@ protected EppoValue getTypedAssignment(
156167
}
157168

158169
if (assignedValue != null && assignmentLogger != null && evaluationResult.doLog()) {
159-
String allocationKey = evaluationResult.getAllocationKey();
160-
String experimentKey =
161-
flagKey
162-
+ '-'
163-
+ allocationKey; // Our experiment key is derived by hyphenating the flag key and
164-
// allocation key
165-
String variationKey = evaluationResult.getVariation().getKey();
166-
Map<String, String> extraLogging = evaluationResult.getExtraLogging();
167-
Map<String, String> metaData = buildLogMetaData(config.isConfigObfuscated());
168-
169-
Assignment assignment =
170-
new Assignment(
171-
experimentKey,
172-
flagKey,
173-
allocationKey,
174-
variationKey,
175-
subjectKey,
176-
subjectAttributes,
177-
extraLogging,
178-
metaData);
170+
179171
try {
180-
assignmentLogger.logAssignment(assignment);
172+
String allocationKey = evaluationResult.getAllocationKey();
173+
String experimentKey =
174+
flagKey
175+
+ '-'
176+
+ allocationKey; // Our experiment key is derived by hyphenating the flag key and
177+
// allocation key
178+
String variationKey = evaluationResult.getVariation().getKey();
179+
Map<String, String> extraLogging = evaluationResult.getExtraLogging();
180+
Map<String, String> metaData = buildLogMetaData(config.isConfigObfuscated());
181+
182+
Assignment assignment =
183+
new Assignment(
184+
experimentKey,
185+
flagKey,
186+
allocationKey,
187+
variationKey,
188+
subjectKey,
189+
subjectAttributes,
190+
extraLogging,
191+
metaData);
192+
193+
// Deduplication of assignment logging is possible by providing an `IAssignmentCache`.
194+
// Default to true, only avoid logging if there's a cache hit.
195+
boolean logAssignment = true;
196+
AssignmentCacheEntry cacheEntry = AssignmentCacheEntry.fromVariationAssignment(assignment);
197+
if (assignmentCache != null) {
198+
if (assignmentCache.hasEntry(cacheEntry)) {
199+
logAssignment = false;
200+
}
201+
}
202+
203+
if (logAssignment) {
204+
assignmentLogger.logAssignment(assignment);
205+
206+
if (assignmentCache != null) {
207+
assignmentCache.put(cacheEntry);
208+
}
209+
}
210+
181211
} catch (Exception e) {
182-
log.warn("Error logging assignment: {}", e.getMessage(), e);
212+
log.error("Error logging assignment: {}", e.getMessage(), e);
183213
}
184214
}
185-
186215
return assignedValue != null ? assignedValue : defaultValue;
187216
}
188217

@@ -428,7 +457,23 @@ public BanditResult getBanditAction(
428457
banditResult.getActionAttributes().getCategoricalAttributes(),
429458
buildLogMetaData(config.isConfigObfuscated()));
430459

431-
banditLogger.logBanditAssignment(banditAssignment);
460+
// Log, only if there is no cache hit.
461+
boolean logBanditAssignment = true;
462+
AssignmentCacheEntry cacheEntry =
463+
AssignmentCacheEntry.fromBanditAssignment(banditAssignment);
464+
if (banditAssignmentCache != null) {
465+
if (banditAssignmentCache.hasEntry(cacheEntry)) {
466+
logBanditAssignment = false;
467+
}
468+
}
469+
470+
if (logBanditAssignment) {
471+
banditLogger.logBanditAssignment(banditAssignment);
472+
473+
if (banditAssignmentCache != null) {
474+
banditAssignmentCache.put(cacheEntry);
475+
}
476+
}
432477
} catch (Exception e) {
433478
log.warn("Error logging bandit assignment: {}", e.getMessage(), e);
434479
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package cloud.eppo.api;
2+
3+
import cloud.eppo.cache.AssignmentCacheEntry;
4+
import cloud.eppo.cache.AssignmentCacheKey;
5+
import java.util.Map;
6+
7+
/**
8+
* {@link IAssignmentCache} implementation which takes a map to use as the underlying storage
9+
* mechanism.
10+
*/
11+
public abstract class AbstractAssignmentCache implements IAssignmentCache {
12+
protected final Map<String, String> delegate;
13+
14+
protected AbstractAssignmentCache(final Map<String, String> delegate) {
15+
this.delegate = delegate;
16+
}
17+
18+
@Override
19+
public boolean hasEntry(AssignmentCacheEntry entry) {
20+
String serializedEntry = get(entry.getKey());
21+
return serializedEntry != null && serializedEntry.equals(entry.getValueKeyString());
22+
}
23+
24+
private String get(AssignmentCacheKey key) {
25+
return delegate.get(key.toString());
26+
}
27+
28+
@Override
29+
public void put(AssignmentCacheEntry entry) {
30+
delegate.put(entry.getKeyString(), entry.getValueKeyString());
31+
}
32+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package cloud.eppo.api;
2+
3+
import cloud.eppo.cache.AssignmentCacheEntry;
4+
5+
/**
6+
* A cache capable of storing the key components of assignments (both variation and bandit) to
7+
* determine both presence and uniqueness of the cached value.
8+
*/
9+
public interface IAssignmentCache {
10+
void put(AssignmentCacheEntry entry);
11+
12+
/**
13+
* Determines whether the entry is present. Implementations must first check for presence by using
14+
* the `{@link AssignmentCacheEntry}.getKey()` method and then whether the cached value matches by
15+
* comparing the `getValueKeyString()` method results.
16+
*/
17+
boolean hasEntry(AssignmentCacheEntry entry);
18+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package cloud.eppo.cache;
2+
3+
import cloud.eppo.logging.Assignment;
4+
import cloud.eppo.logging.BanditAssignment;
5+
import java.util.Objects;
6+
import org.jetbrains.annotations.NotNull;
7+
8+
public class AssignmentCacheEntry {
9+
private final AssignmentCacheKey key;
10+
private final AssignmentCacheValue value;
11+
12+
public AssignmentCacheEntry(
13+
@NotNull AssignmentCacheKey key, @NotNull AssignmentCacheValue value) {
14+
this.key = key;
15+
this.value = value;
16+
}
17+
18+
public static AssignmentCacheEntry fromVariationAssignment(Assignment assignment) {
19+
return new AssignmentCacheEntry(
20+
new AssignmentCacheKey(assignment.getSubject(), assignment.getFeatureFlag()),
21+
new VariationCacheValue(assignment.getAllocation(), assignment.getVariation()));
22+
}
23+
24+
public static AssignmentCacheEntry fromBanditAssignment(BanditAssignment assignment) {
25+
return new AssignmentCacheEntry(
26+
new AssignmentCacheKey(assignment.getSubject(), assignment.getFeatureFlag()),
27+
new BanditCacheValue(assignment.getBandit(), assignment.getAction()));
28+
}
29+
30+
@NotNull public AssignmentCacheKey getKey() {
31+
return key;
32+
}
33+
34+
@NotNull public String getKeyString() {
35+
return key.toString();
36+
}
37+
38+
@NotNull public String getValueKeyString() {
39+
return value.getValueIdentifier();
40+
}
41+
42+
@NotNull public AssignmentCacheValue getValue() {
43+
return value;
44+
}
45+
46+
@Override
47+
public boolean equals(Object o) {
48+
if (this == o) return true;
49+
if (o == null || getClass() != o.getClass()) return false;
50+
AssignmentCacheEntry that = (AssignmentCacheEntry) o;
51+
return Objects.equals(key, that.key)
52+
&& Objects.equals(value.getValueIdentifier(), that.value.getValueIdentifier());
53+
}
54+
55+
@Override
56+
public int hashCode() {
57+
return Objects.hash(key, value);
58+
}
59+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package cloud.eppo.cache;
2+
3+
import java.util.Objects;
4+
5+
/**
6+
* Assignment cache keys are only on the subject and flag level, while a combination of keys and
7+
* fields are used for uniqueness checking. This way, if an assigned variation or bandit action
8+
* changes for a flag, it evicts the old one. Then, if an older assignment is later reassigned, it
9+
* will be treated as new.
10+
*/
11+
public class AssignmentCacheKey {
12+
private final String subjectKey;
13+
private final String flagKey;
14+
15+
public AssignmentCacheKey(String subjectKey, String flagKey) {
16+
this.subjectKey = subjectKey;
17+
this.flagKey = flagKey;
18+
}
19+
20+
public String getSubjectKey() {
21+
return subjectKey;
22+
}
23+
24+
public String getFlagKey() {
25+
return flagKey;
26+
}
27+
28+
@Override
29+
public String toString() {
30+
return subjectKey + ";" + flagKey;
31+
}
32+
33+
@Override
34+
public boolean equals(Object o) {
35+
if (this == o) return true;
36+
if (o == null || getClass() != o.getClass()) return false;
37+
AssignmentCacheKey that = (AssignmentCacheKey) o;
38+
return Objects.equals(toString(), that.toString());
39+
}
40+
41+
@Override
42+
public int hashCode() {
43+
return Objects.hash(subjectKey, flagKey);
44+
}
45+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package cloud.eppo.cache;
2+
3+
import java.io.Serializable;
4+
5+
public interface AssignmentCacheValue extends Serializable {
6+
String getValueIdentifier();
7+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package cloud.eppo.cache;
2+
3+
import java.util.Objects;
4+
5+
public class BanditCacheValue implements AssignmentCacheValue {
6+
private final String banditKey;
7+
private final String actionKey;
8+
9+
public BanditCacheValue(String banditKey, String actionKey) {
10+
this.banditKey = banditKey;
11+
this.actionKey = actionKey;
12+
}
13+
14+
@Override
15+
public String getValueIdentifier() {
16+
return banditKey + ";" + actionKey;
17+
}
18+
19+
@Override
20+
public boolean equals(Object o) {
21+
if (this == o) return true;
22+
if (o == null || getClass() != o.getClass()) return false;
23+
BanditCacheValue that = (BanditCacheValue) o;
24+
return Objects.equals(banditKey, that.banditKey) && Objects.equals(actionKey, that.actionKey);
25+
}
26+
27+
@Override
28+
public int hashCode() {
29+
return Objects.hash(banditKey, actionKey);
30+
}
31+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package cloud.eppo.cache;
2+
3+
import cloud.eppo.api.AbstractAssignmentCache;
4+
import java.util.Collections;
5+
import java.util.Map;
6+
import java.util.concurrent.TimeUnit;
7+
import org.apache.commons.collections4.map.PassiveExpiringMap;
8+
9+
public class ExpiringInMemoryAssignmentCache extends AbstractAssignmentCache {
10+
public ExpiringInMemoryAssignmentCache(int cacheTimeout, TimeUnit timeUnit) {
11+
super(Collections.synchronizedMap(new PassiveExpiringMap<>(cacheTimeout, timeUnit)));
12+
}
13+
14+
public ExpiringInMemoryAssignmentCache(
15+
Map<String, String> delegate, int cacheTimeout, TimeUnit timeUnit) {
16+
super(Collections.synchronizedMap(new PassiveExpiringMap<>(cacheTimeout, timeUnit, delegate)));
17+
}
18+
}

0 commit comments

Comments
 (0)