Skip to content

Commit 78a13f8

Browse files
Superposition Botmahatoankitkumar
authored andcommitted
chore(version): v0.102.0 [skip ci]
1 parent d0197c1 commit 78a13f8

3 files changed

Lines changed: 152 additions & 18 deletions

File tree

clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/RefreshJob.java

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
import org.slf4j.Logger;
66
import org.slf4j.LoggerFactory;
77

8+
import java.lang.ref.WeakReference;
89
import java.util.Optional;
910
import java.util.concurrent.*;
11+
import java.util.concurrent.atomic.AtomicReference;
1012
import java.util.function.Supplier;
1113

1214
interface RefreshJob<T> {
@@ -19,46 +21,79 @@ interface RefreshJob<T> {
1921
final class Poll<T> implements RefreshJob<T> {
2022
private final RefreshStrategy.Polling config;
2123
private final Supplier<CompletableFuture<T>> action;
22-
private final CompletableFuture<T> output;
24+
private final Runnable onChange;
25+
private final CompletableFuture<T> firstOutput;
26+
private volatile T latestOutput = null;
2327
private ScheduledFuture<?> poll;
2428

25-
Poll(RefreshStrategy.Polling config, Supplier<CompletableFuture<T>> action) {
29+
Poll(RefreshStrategy.Polling config, Supplier<CompletableFuture<T>> action, Runnable onChange) {
2630
this.config = config;
2731
this.action = action;
28-
this.output = new CompletableFuture<>();
32+
this.onChange = onChange;
33+
this.firstOutput = new CompletableFuture<>();
2934
}
3035

3136
void start() {
3237
log.debug("Starting polling-refresh.");
33-
poll = SEXEC.schedule(
38+
WeakReference<Poll<T>> weakSelf = new WeakReference<>(this);
39+
AtomicReference<ScheduledFuture<?>> taskRef = new AtomicReference<>();
40+
41+
ScheduledFuture<?> scheduled = SEXEC.scheduleAtFixedRate(
3442
() -> {
35-
var o = RefreshJob.runRefreshWithTimeout(action, config.timeout);
43+
Poll<T> self = weakSelf.get();
44+
if (self == null) {
45+
log.debug("Poll referent GC'd — self-cancelling polling task.");
46+
ScheduledFuture<?> t = taskRef.get();
47+
if (t != null) t.cancel(false);
48+
return;
49+
}
50+
var o = RefreshJob.runRefreshWithTimeout(self.action, self.config.timeout);
3651
if (o != null) {
37-
output.complete(o);
52+
boolean changed = !o.equals(self.latestOutput);
53+
self.latestOutput = o;
54+
if (!self.firstOutput.isDone()) {
55+
self.firstOutput.complete(o);
56+
}
57+
if (changed && self.onChange != null) {
58+
try {
59+
self.onChange.run();
60+
} catch (Exception e) {
61+
log.error("onChange callback error: {}", e.getMessage());
62+
}
63+
} else if (!changed) {
64+
log.debug("Output unchanged, skipping onChange callback.");
65+
}
3866
}
3967
},
68+
0,
4069
config.interval,
4170
TimeUnit.MILLISECONDS
4271
);
72+
73+
taskRef.set(scheduled);
74+
this.poll = scheduled;
4375
}
4476

4577
@Override
4678
public Optional<T> getOutput() {
79+
if (latestOutput != null) {
80+
return Optional.of(latestOutput);
81+
}
4782
try {
4883
if (poll == null) {
4984
log.warn("Polling hasn't started but the output is being used.");
50-
} else if (!poll.isCancelled() && !output.isDone()) {
51-
return Optional.ofNullable(output.get(config.timeout, TimeUnit.MILLISECONDS));
85+
} else if (!firstOutput.isDone()) {
86+
return Optional.ofNullable(firstOutput.get(config.timeout, TimeUnit.MILLISECONDS));
5287
}
5388
} catch (Exception e) {
5489
log.warn("Attempted to await for poll output but an exception occurred: {}", e.toString());
5590
}
56-
return Optional.ofNullable(output.getNow(null));
91+
return Optional.ofNullable(latestOutput);
5792
}
5893

5994
@Override
6095
public void shutdown() {
61-
if (!poll.isCancelled()) {
96+
if (poll != null && !poll.isCancelled()) {
6297
log.debug("Shutting down polling-refresh.");
6398
poll.cancel(false);
6499
}
@@ -71,11 +106,13 @@ final class OnDemand<T> implements RefreshJob<T> {
71106
private T output = null;
72107
private final RefreshStrategy.OnDemand config;
73108
private final Supplier<CompletableFuture<T>> action;
109+
private final Runnable onChange;
74110
private boolean stopped = false;
75111

76-
OnDemand(RefreshStrategy.OnDemand config, Supplier<CompletableFuture<T>> action) {
112+
OnDemand(RefreshStrategy.OnDemand config, Supplier<CompletableFuture<T>> action, Runnable onChange) {
77113
this.config = config;
78114
this.action = action;
115+
this.onChange = onChange;
79116
}
80117

81118
@Override
@@ -85,8 +122,18 @@ public Optional<T> getOutput() {
85122
log.debug("Running refresh as current output is stale.");
86123
var o = RefreshJob.runRefreshWithTimeout(action, config.timeout);
87124
if (o != null) {
125+
boolean changed = !o.equals(output);
88126
output = o;
89127
lastUpdated = System.currentTimeMillis();
128+
if (changed && onChange != null) {
129+
try {
130+
onChange.run();
131+
} catch (Exception e) {
132+
log.error("onChange callback error: {}", e.getMessage());
133+
}
134+
} else if (!changed) {
135+
log.debug("Output unchanged, skipping onChange callback.");
136+
}
90137
}
91138
} else {
92139
log.debug("Current output is fresh, no refresh required.");
@@ -114,10 +161,14 @@ private static<T> T runRefreshWithTimeout(Supplier<CompletableFuture<T>> action,
114161
}
115162

116163
static <T> RefreshJob<T> create(RefreshStrategy config, Supplier<CompletableFuture<T>> action) {
164+
return create(config, action, null);
165+
}
166+
167+
static <T> RefreshJob<T> create(RefreshStrategy config, Supplier<CompletableFuture<T>> action, Runnable onChange) {
117168
if (config instanceof RefreshStrategy.Polling) {
118-
return new Poll<>((RefreshStrategy.Polling)config, action);
169+
return new Poll<>((RefreshStrategy.Polling)config, action, onChange);
119170
} else if (config instanceof RefreshStrategy.OnDemand) {
120-
return new OnDemand<>((RefreshStrategy.OnDemand)config, action);
171+
return new OnDemand<>((RefreshStrategy.OnDemand)config, action, onChange);
121172
}
122173
throw new IllegalArgumentException("Invalid refresh-strategy: " + config);
123174
}

clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/SuperpositionOpenFeatureProvider.java

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,15 @@
1515
import uniffi.superposition_client.FfiExperiment;
1616
import uniffi.superposition_client.FfiExperimentGroup;
1717
import uniffi.superposition_client.OperationException;
18+
import uniffi.superposition_client.ProviderCache;
19+
import uniffi.superposition_types.MergeStrategy;
1820

1921
import java.util.List;
2022
import java.util.Map;
2123
import java.util.Optional;
24+
import java.util.concurrent.CompletableFuture;
25+
import java.util.concurrent.TimeUnit;
26+
import java.util.concurrent.TimeoutException;
2227
import java.util.stream.Collectors;
2328

2429
/**
@@ -65,11 +70,14 @@
6570
public class SuperpositionOpenFeatureProvider implements FeatureProvider {
6671
private static final Gson gson = new Gson();
6772
private final SuperpositionAsyncClient sdk;
73+
private final ProviderCache cache;
6874
private final RefreshJob<EvaluationArgs> configRefresh;
6975
private final Optional<RefreshJob<List<FfiExperiment>>> expRefresh;
7076
private final Optional<RefreshJob<List<FfiExperimentGroup>>> expGroupRefresh;
7177
private Optional<EvaluationContext> defaultCtx;
7278
private final Optional<EvaluationArgs> fallbackArgs;
79+
private final CompletableFuture<Void> cacheReady = new CompletableFuture<>();
80+
private final int configTimeout;
7381

7482
public SuperpositionOpenFeatureProvider(@NonNull SuperpositionProviderOptions options) {
7583
if (options.fallbackConfig != null) {
@@ -84,14 +92,18 @@ public SuperpositionOpenFeatureProvider(@NonNull SuperpositionProviderOptions op
8492
builder.transport(options.transport);
8593
}
8694
this.sdk = builder.build();
95+
this.cache = new ProviderCache();
96+
this.configTimeout = options.refreshStrategy.getTimeout();
97+
8798
var getConfigInput = GetConfigInput.builder()
8899
.context(Map.of())
89100
.orgId(options.orgId)
90101
.workspaceId(options.workspaceId)
91102
.build();
92103
this.configRefresh = RefreshJob.create(
93104
options.refreshStrategy,
94-
() -> sdk.getConfig(getConfigInput).thenApply(EvaluationArgs::new)
105+
() -> sdk.getConfig(getConfigInput).thenApply(EvaluationArgs::new),
106+
this::reinitConfigCache
95107
);
96108

97109
if (options.experimentationOptions != null) {
@@ -109,7 +121,8 @@ public SuperpositionOpenFeatureProvider(@NonNull SuperpositionProviderOptions op
109121
.stream()
110122
.map(EvaluationArgs.Helpers::toFfiExperiment)
111123
.toList()
112-
))
124+
),
125+
this::reinitExperimentsCache)
113126
);
114127

115128
// New logic for experiment_groups
@@ -126,7 +139,8 @@ public SuperpositionOpenFeatureProvider(@NonNull SuperpositionProviderOptions op
126139
.stream()
127140
.map(EvaluationArgs.Helpers::toFfiExperimentGroup)
128141
.toList()
129-
))
142+
),
143+
this::reinitExperimentsCache)
130144
);
131145
} else {
132146
this.expRefresh = Optional.empty();
@@ -162,6 +176,9 @@ public void initialize(EvaluationContext eCtx) {
162176
@Override
163177
public void shutdown() {
164178
configRefresh.shutdown();
179+
expRefresh.ifPresent(RefreshJob::shutdown);
180+
expGroupRefresh.ifPresent(RefreshJob::shutdown);
181+
cache.close();
165182
}
166183

167184
@SneakyThrows
@@ -285,9 +302,19 @@ private EvaluationArgs getEvaluationArgs(EvaluationContext ctx) throws Exception
285302
}
286303

287304
private Map<String, String> evaluateConfigInternal(EvaluationContext ctx) throws Exception {
288-
EvaluationArgs args = getEvaluationArgs(ctx);
305+
// Block until cache.initConfig has been called (completed inside reinitConfigCache).
306+
// This guarantees cache.evalConfig never runs on an uninitialized cache.
307+
if (!cacheReady.isDone()) {
308+
try {
309+
cacheReady.get(configTimeout, TimeUnit.MILLISECONDS);
310+
} catch (TimeoutException e) {
311+
throw new Exception("Config cache not initialized within timeout (" + configTimeout + "ms).");
312+
}
313+
}
289314
var ctx_ = defaultCtx.isPresent() ? ctx.merge(defaultCtx.get()) : ctx;
290-
return args.evaluate(ctx_, getExperimentationArgs(ctx_));
315+
var queryData = EvaluationArgs.Companion.buildQueryData(ctx_);
316+
String targetingKey = ctx_.getTargetingKey();
317+
return cache.evalConfig(queryData, MergeStrategy.MERGE, null, targetingKey);
291318
}
292319

293320
private List<String> getApplicableVariantsInternal(EvaluationContext ctx) throws Exception {
@@ -314,4 +341,38 @@ private ExperimentationArgs getExperimentationArgs(EvaluationContext ctx) {
314341
}
315342
return null;
316343
}
344+
345+
private void reinitConfigCache() {
346+
var out = configRefresh.getOutput();
347+
if (out.isPresent()) {
348+
var args = out.get();
349+
try {
350+
cache.initConfig(
351+
args.getDefaultConfig(),
352+
args.getContexts(),
353+
args.getOverrides(),
354+
args.getDimensions()
355+
);
356+
cacheReady.complete(null);
357+
log.debug("Config cache re-initialized");
358+
} catch (Exception e) {
359+
log.error("Failed to reinitialize config cache: {}", e.getMessage());
360+
}
361+
}
362+
}
363+
364+
private void reinitExperimentsCache() {
365+
if (expRefresh.isPresent() && expGroupRefresh.isPresent()) {
366+
var exps = expRefresh.get().getOutput();
367+
var groups = expGroupRefresh.get().getOutput();
368+
if (exps.isPresent() && groups.isPresent()) {
369+
try {
370+
cache.initExperiments(exps.get(), groups.get());
371+
log.debug("Experiments cache re-initialized");
372+
} catch (Exception e) {
373+
log.error("Failed to reinitialize experiments cache: {}", e.getMessage());
374+
}
375+
}
376+
}
377+
}
317378
}

clients/java/openfeature-provider/src/main/kotlin/io/juspay/superposition/openfeature/EvaluationArgs.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,23 @@ internal class EvaluationArgs {
129129
}
130130
}
131131

132+
override fun equals(other: Any?): Boolean {
133+
if (this === other) return true
134+
if (other !is EvaluationArgs) return false
135+
return defaultConfig == other.defaultConfig &&
136+
contexts == other.contexts &&
137+
overrides == other.overrides &&
138+
dimensions == other.dimensions
139+
}
140+
141+
override fun hashCode(): Int {
142+
var result = defaultConfig.hashCode()
143+
result = 31 * result + contexts.hashCode()
144+
result = 31 * result + overrides.hashCode()
145+
result = 31 * result + dimensions.hashCode()
146+
return result
147+
}
148+
132149
companion object {
133150
private val gson = Gson()
134151

@@ -144,6 +161,11 @@ internal class EvaluationArgs {
144161
return m.mapValues { valueToJsonString(it.value) }
145162
}
146163

164+
@JvmStatic
165+
fun buildQueryData(eContext: EvaluationContext): Map<String, String> {
166+
return toQueryData(eContext)
167+
}
168+
147169
private fun serializeDocument(d: Document): String {
148170
return valueToJsonString(d.asObject())
149171
}

0 commit comments

Comments
 (0)