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
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.lang.ref.WeakReference;
import java.util.Optional;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;

interface RefreshJob<T> {
Expand All @@ -19,46 +21,79 @@ interface RefreshJob<T> {
final class Poll<T> implements RefreshJob<T> {
private final RefreshStrategy.Polling config;
private final Supplier<CompletableFuture<T>> action;
private final CompletableFuture<T> output;
private final Runnable onChange;
private final CompletableFuture<T> firstOutput;
private volatile T latestOutput = null;
Comment on lines +25 to +26
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why do we need both of these?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

So latestOutput is the steady-state store and firstOutput is purely the one-time synchronization primitive for that cold-start window. We could replace both with a single AtomicReference<CompletableFuture> (replacing it on each poll with an already-completed future), but that would allocate a new CompletableFuture on every poll cycle. The current change is more efficient.

private ScheduledFuture<?> poll;

Poll(RefreshStrategy.Polling config, Supplier<CompletableFuture<T>> action) {
Poll(RefreshStrategy.Polling config, Supplier<CompletableFuture<T>> action, Runnable onChange) {
this.config = config;
this.action = action;
this.output = new CompletableFuture<>();
this.onChange = onChange;
this.firstOutput = new CompletableFuture<>();
}

void start() {
log.debug("Starting polling-refresh.");
poll = SEXEC.schedule(
WeakReference<Poll<T>> weakSelf = new WeakReference<>(this);
AtomicReference<ScheduledFuture<?>> taskRef = new AtomicReference<>();

ScheduledFuture<?> scheduled = SEXEC.scheduleAtFixedRate(
() -> {
var o = RefreshJob.runRefreshWithTimeout(action, config.timeout);
Poll<T> self = weakSelf.get();
if (self == null) {
log.debug("Poll referent GC'd — self-cancelling polling task.");
ScheduledFuture<?> t = taskRef.get();
if (t != null) t.cancel(false);
return;
}
var o = RefreshJob.runRefreshWithTimeout(self.action, self.config.timeout);
if (o != null) {
output.complete(o);
boolean changed = !o.equals(self.latestOutput);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Generally the equals method provided by Object.class just does a referencial check (== or just checks if the pointer is the same), not a value check unless you define a custom implementation. Given T is generic, we should use some other way of checking if data has changed here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

it's always a Map<String, Object>), so this works here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we scope it in such a way then if possible?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

pushed the change with custom equals()

self.latestOutput = o;
if (!self.firstOutput.isDone()) {
self.firstOutput.complete(o);
}
if (changed && self.onChange != null) {
try {
self.onChange.run();
} catch (Exception e) {
log.error("onChange callback error: {}", e.getMessage());
}
} else if (!changed) {
log.debug("Output unchanged, skipping onChange callback.");
Comment on lines +50 to +64
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e

echo "== EvaluationArgs declaration =="
fd 'EvaluationArgs.kt$' --exec sed -n '20,80p' {}

echo
echo "== Provider creates a new EvaluationArgs for each config refresh =="
fd 'SuperpositionOpenFeatureProvider.java$' --exec rg -n 'thenApply\(EvaluationArgs::new\)' {}

echo
echo "== Poll change detector =="
fd 'RefreshJob.java$' --exec rg -n 'equals\(self\.latestOutput\)|latestOutput' {}

Repository: juspay/superposition

Length of output: 2994


equals() falls back to reference identity for config change detection.

EvaluationArgs is a regular Kotlin class (not a data class), and configRefresh creates a fresh instance on every poll via thenApply(EvaluationArgs::new). The comparison at line 58 therefore always returns false, causing onChange to fire every refresh cycle regardless of whether the actual config changed.

To fix this, either make EvaluationArgs a data class or implement equals() and hashCode() based on the struct fields (defaultConfig, contexts, overrides, dimensions).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/RefreshJob.java`
around lines 56 - 70, The equality check on the refreshed config uses
EvaluationArgs instances created each poll (via configRefresh ->
thenApply(EvaluationArgs::new)), so equals() falls back to reference identity
and triggers onChange every time; fix by making EvaluationArgs a Kotlin data
class or add proper equals() and hashCode() implementations that compare the
core fields (defaultConfig, contexts, overrides, dimensions) so that
RefreshJob's changed detection (comparing o with self.latestOutput in
runRefreshWithTimeout / RefreshJob) correctly detects real changes.

}
}
},
0,
config.interval,
TimeUnit.MILLISECONDS
);

taskRef.set(scheduled);
this.poll = scheduled;
}

@Override
public Optional<T> getOutput() {
if (latestOutput != null) {
return Optional.of(latestOutput);
}
try {
if (poll == null) {
log.warn("Polling hasn't started but the output is being used.");
} else if (!poll.isCancelled() && !output.isDone()) {
return Optional.ofNullable(output.get(config.timeout, TimeUnit.MILLISECONDS));
} else if (!firstOutput.isDone()) {
return Optional.ofNullable(firstOutput.get(config.timeout, TimeUnit.MILLISECONDS));
}
} catch (Exception e) {
log.warn("Attempted to await for poll output but an exception occurred: {}", e.toString());
}
return Optional.ofNullable(output.getNow(null));
return Optional.ofNullable(latestOutput);
}

@Override
public void shutdown() {
if (!poll.isCancelled()) {
if (poll != null && !poll.isCancelled()) {
log.debug("Shutting down polling-refresh.");
poll.cancel(false);
}
Expand All @@ -71,11 +106,13 @@ final class OnDemand<T> implements RefreshJob<T> {
private T output = null;
private final RefreshStrategy.OnDemand config;
private final Supplier<CompletableFuture<T>> action;
private final Runnable onChange;
private boolean stopped = false;

OnDemand(RefreshStrategy.OnDemand config, Supplier<CompletableFuture<T>> action) {
OnDemand(RefreshStrategy.OnDemand config, Supplier<CompletableFuture<T>> action, Runnable onChange) {
this.config = config;
this.action = action;
this.onChange = onChange;
}

@Override
Expand All @@ -85,8 +122,18 @@ public Optional<T> getOutput() {
log.debug("Running refresh as current output is stale.");
var o = RefreshJob.runRefreshWithTimeout(action, config.timeout);
if (o != null) {
boolean changed = !o.equals(output);
output = o;
lastUpdated = System.currentTimeMillis();
if (changed && onChange != null) {
try {
onChange.run();
} catch (Exception e) {
log.error("onChange callback error: {}", e.getMessage());
}
} else if (!changed) {
log.debug("Output unchanged, skipping onChange callback.");
}
}
} else {
log.debug("Current output is fresh, no refresh required.");
Expand Down Expand Up @@ -114,10 +161,14 @@ private static<T> T runRefreshWithTimeout(Supplier<CompletableFuture<T>> action,
}

static <T> RefreshJob<T> create(RefreshStrategy config, Supplier<CompletableFuture<T>> action) {
return create(config, action, null);
}

static <T> RefreshJob<T> create(RefreshStrategy config, Supplier<CompletableFuture<T>> action, Runnable onChange) {
if (config instanceof RefreshStrategy.Polling) {
return new Poll<>((RefreshStrategy.Polling)config, action);
return new Poll<>((RefreshStrategy.Polling)config, action, onChange);
} else if (config instanceof RefreshStrategy.OnDemand) {
return new OnDemand<>((RefreshStrategy.OnDemand)config, action);
return new OnDemand<>((RefreshStrategy.OnDemand)config, action, onChange);
}
throw new IllegalArgumentException("Invalid refresh-strategy: " + config);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,15 @@
import uniffi.superposition_client.FfiExperiment;
import uniffi.superposition_client.FfiExperimentGroup;
import uniffi.superposition_client.OperationException;
import uniffi.superposition_client.ProviderCache;
import uniffi.superposition_types.MergeStrategy;

import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;

/**
Expand Down Expand Up @@ -65,11 +70,14 @@
public class SuperpositionOpenFeatureProvider implements FeatureProvider {
private static final Gson gson = new Gson();
private final SuperpositionAsyncClient sdk;
private final ProviderCache cache;
private final RefreshJob<EvaluationArgs> configRefresh;
private final Optional<RefreshJob<List<FfiExperiment>>> expRefresh;
private final Optional<RefreshJob<List<FfiExperimentGroup>>> expGroupRefresh;
private Optional<EvaluationContext> defaultCtx;
private final Optional<EvaluationArgs> fallbackArgs;
private final CompletableFuture<Void> cacheReady = new CompletableFuture<>();
private final int configTimeout;

public SuperpositionOpenFeatureProvider(@NonNull SuperpositionProviderOptions options) {
if (options.fallbackConfig != null) {
Expand All @@ -84,14 +92,18 @@ public SuperpositionOpenFeatureProvider(@NonNull SuperpositionProviderOptions op
builder.transport(options.transport);
}
this.sdk = builder.build();
this.cache = new ProviderCache();
this.configTimeout = options.refreshStrategy.getTimeout();

var getConfigInput = GetConfigInput.builder()
.context(Map.of())
.orgId(options.orgId)
.workspaceId(options.workspaceId)
.build();
this.configRefresh = RefreshJob.create(
options.refreshStrategy,
() -> sdk.getConfig(getConfigInput).thenApply(EvaluationArgs::new)
() -> sdk.getConfig(getConfigInput).thenApply(EvaluationArgs::new),
this::reinitConfigCache
);

if (options.experimentationOptions != null) {
Expand All @@ -109,7 +121,8 @@ public SuperpositionOpenFeatureProvider(@NonNull SuperpositionProviderOptions op
.stream()
.map(EvaluationArgs.Helpers::toFfiExperiment)
.toList()
))
),
this::reinitExperimentsCache)
);

// New logic for experiment_groups
Expand All @@ -126,7 +139,8 @@ public SuperpositionOpenFeatureProvider(@NonNull SuperpositionProviderOptions op
.stream()
.map(EvaluationArgs.Helpers::toFfiExperimentGroup)
.toList()
))
),
this::reinitExperimentsCache)
);
} else {
this.expRefresh = Optional.empty();
Expand Down Expand Up @@ -162,6 +176,9 @@ public void initialize(EvaluationContext eCtx) {
@Override
public void shutdown() {
configRefresh.shutdown();
expRefresh.ifPresent(RefreshJob::shutdown);
expGroupRefresh.ifPresent(RefreshJob::shutdown);
cache.close();
}

@SneakyThrows
Expand Down Expand Up @@ -285,9 +302,19 @@ private EvaluationArgs getEvaluationArgs(EvaluationContext ctx) throws Exception
}

private Map<String, String> evaluateConfigInternal(EvaluationContext ctx) throws Exception {
EvaluationArgs args = getEvaluationArgs(ctx);
// Block until cache.initConfig has been called (completed inside reinitConfigCache).
// This guarantees cache.evalConfig never runs on an uninitialized cache.
if (!cacheReady.isDone()) {
try {
cacheReady.get(configTimeout, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
throw new Exception("Config cache not initialized within timeout (" + configTimeout + "ms).");
}
Comment on lines 304 to +312
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Mar 26, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

cacheReady is waiting on a callback that on-demand refresh never fires.

In RefreshStrategy.OnDemand, reinitConfigCache() only runs as the onChange callback from configRefresh.getOutput(). This method blocks on cacheReady before any getOutput() call, so on-demand providers will time out on every evaluation instead of lazily priming the cache.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/SuperpositionOpenFeatureProvider.java`
around lines 301 - 309, The evaluateConfigInternal method blocks on cacheReady
before any trigger when using RefreshStrategy.OnDemand, causing on-demand
providers to always timeout; modify the logic so OnDemand primes the cache
before waiting: if refreshStrategy == RefreshStrategy.OnDemand (or equivalent),
call reinitConfigCache() or invoke/subscribe to configRefresh.getOutput() to
trigger the onChange callback (which initializes cache and completes cacheReady)
prior to calling cacheReady.get(...), otherwise keep the existing wait
path—update references in evaluateConfigInternal, reinitConfigCache, and where
configRefresh.getOutput() is used so on-demand evaluation lazily primes the
cache rather than timing out.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ankit can you check this?

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.

🧩 Analysis chain

Let me inspect the RefreshJob.java implementation to verify when onChange fires in OnDemand mode.


🏁 Script executed:

#!/bin/bash
cat clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/RefreshJob.java

Length of output: 7032


@Datron — the original review comment is confirmed correct. Here's a detailed breakdown of both issues found:


🔴 Bug 1: OnDemand mode always times out (the flagged issue)

In OnDemand mode, cacheReady is never completed because:

  • cacheReady.complete(null) is only called inside reinitConfigCache().
  • reinitConfigCache() is only called as the onChange callback.
  • onChange is only called inside OnDemand.getOutput() after a successful refresh.
  • But evaluateConfigInternal blocks on cacheReady before ever calling configRefresh.getOutput(), so getOutput() is never triggered, onChange never fires, and the wait always hits the timeout.

Fix for evaluateConfigInternal: trigger an eager refresh for OnDemand before blocking:

private Map<String, String> evaluateConfigInternal(EvaluationContext ctx) throws Exception {
    if (!cacheReady.isDone()) {
        // For OnDemand, no background loop fires onChange; we must trigger getOutput()
        // explicitly so the chain: refresh → onChange → reinitConfigCache → cacheReady.complete() runs.
        if (!(configRefresh instanceof RefreshJob.Poll)) {
            configRefresh.getOutput();
        }
        try {
            cacheReady.get(configTimeout, TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            throw new Exception("Config cache not initialized within timeout (" + configTimeout + "ms).");
        }
    }
    var ctx_ = defaultCtx.isPresent() ? ctx.merge(defaultCtx.get()) : ctx;
    var queryData = EvaluationArgs.Companion.buildQueryData(ctx_);
    String targetingKey = ctx_.getTargetingKey();
    return cache.evalConfig(queryData, MergeStrategy.MERGE, null, targetingKey);
}

🔴 Bug 2: OnDemand TTL condition is inverted (bonus)

In RefreshJob.java, the staleness check in OnDemand.getOutput() is:

// Line ~119 — condition is inverted
if (lastUpdated - System.currentTimeMillis() < config.ttl) {
  • When lastUpdated = 0 (initial): 0 - huge_positive < ttl → always true ✅ (first call works)
  • After first update where lastUpdated ≈ currentTimeMillis(): ≈0 < ttl → still always true

This means every single getOutput() call fires a network request, completely defeating the TTL caching. The correct condition should be:

// Refresh only when data is actually stale
if (System.currentTimeMillis() - lastUpdated > config.ttl) {

Both bugs should be addressed together. Bug 2 also has a secondary effect on Bug 1's fix — without the TTL fix, the reinitConfigCache() callback (called from within onChange) will trigger a second unnecessary network call via configRefresh.getOutput() before completing cacheReady.


🧠 Learnings used
Learnt from: ayushjain17
Repo: juspay/superposition PR: 951
File: crates/superposition_provider/src/local_provider.rs:271-336
Timestamp: 2026-04-01T17:53:15.684Z
Learning: In `crates/superposition_provider/src/local_provider.rs`, the `do_refresh()` method intentionally allows `cached_config` and `cached_experiments` to be updated independently (potentially out of sync). Temporary divergence between the two caches is an accepted trade-off by design — do not flag partial/non-atomic cache updates in this file as an issue.

}
var ctx_ = defaultCtx.isPresent() ? ctx.merge(defaultCtx.get()) : ctx;
return args.evaluate(ctx_, getExperimentationArgs(ctx_));
var queryData = EvaluationArgs.Companion.buildQueryData(ctx_);
String targetingKey = ctx_.getTargetingKey();
return cache.evalConfig(queryData, MergeStrategy.MERGE, null, targetingKey);
Comment on lines 304 to +317
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

evaluateConfigInternal now waits on cacheReady, but nothing triggers a refresh/initialization when refreshStrategy is OnDemand (since initialize() only starts polling jobs and evaluation no longer calls configRefresh.getOutput()). This means cacheReady may never complete and all evaluations will time out. Consider explicitly triggering a config refresh for OnDemand (e.g., call configRefresh.getOutput() / getEvaluationArgs() before awaiting) and initializing the cache from that output.

Copilot uses AI. Check for mistakes.
Comment on lines 304 to +317
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

This change introduces new behavior around cache initialization (blocking on cacheReady and using ProviderCache.evalConfig(...)), but the existing tests only cover the polling path and don't exercise OnDemand refresh or fallback-config initialization. Adding tests for (1) OnDemand strategy successfully evaluating without timing out and (2) fallback config being used when initial refresh fails would help prevent regressions.

Copilot uses AI. Check for mistakes.
}

private List<String> getApplicableVariantsInternal(EvaluationContext ctx) throws Exception {
Expand All @@ -314,4 +341,38 @@ private ExperimentationArgs getExperimentationArgs(EvaluationContext ctx) {
}
return null;
}

private void reinitConfigCache() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why reinit? Similar question for experiments. Should we just call it refresh? What is the name used in other providers

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

this reinitializes the config cache in rust
refresh are there for fetching it from the server for configs and exps
will make it confusing to have refresh here, and yes its reinit everywhere else.

var out = configRefresh.getOutput();
if (out.isPresent()) {
var args = out.get();
try {
cache.initConfig(
args.getDefaultConfig(),
args.getContexts(),
args.getOverrides(),
args.getDimensions()
);
cacheReady.complete(null);
log.debug("Config cache re-initialized");
} catch (Exception e) {
log.error("Failed to reinitialize config cache: {}", e.getMessage());
}
Comment on lines +347 to +360
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

The new cache-based evaluation path no longer uses fallbackArgs: if the initial config refresh fails, reinitConfigCache() won't run cache.initConfig(...), cacheReady won't complete, and evaluation will fail with a timeout even though a fallback config is available. Consider initializing the cache from fallbackArgs when refresh output is empty (especially on first run), and/or completing cacheReady based on fallback initialization.

Suggested change
if (out.isPresent()) {
var args = out.get();
try {
cache.initConfig(
args.getDefaultConfig(),
args.getContexts(),
args.getOverrides(),
args.getDimensions()
);
cacheReady.complete(null);
log.debug("Config cache re-initialized");
} catch (Exception e) {
log.error("Failed to reinitialize config cache: {}", e.getMessage());
}
var args = out.orElse(fallbackArgs.orElse(null));
if (args == null) {
log.warn("Config refresh output is unavailable and no fallback config is present.");
return;
}
try {
cache.initConfig(
args.getDefaultConfig(),
args.getContexts(),
args.getOverrides(),
args.getDimensions()
);
cacheReady.complete(null);
if (out.isPresent()) {
log.debug("Config cache re-initialized");
} else {
log.warn("Config refresh output unavailable; initialized config cache from fallback config.");
}
} catch (Exception e) {
log.error("Failed to reinitialize config cache: {}", e.getMessage());

Copilot uses AI. Check for mistakes.
}
Comment on lines +345 to +361
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.

⚠️ Potential issue | 🟠 Major

Fallback config no longer seeds the cache.

If the first remote refresh never produces output, this method just returns without calling cache.initConfig(...) even when fallbackConfig was provided. The old evaluation path used fallbackArgs; the new cache path now drops straight to the default / PROVIDER_NOT_READY result instead.

🛠️ One way to preserve the old fallback behavior
     private void reinitConfigCache() {
-        var out = configRefresh.getOutput();
-        if (out.isPresent()) {
-            var args = out.get();
+        var args = configRefresh.getOutput().orElse(fallbackArgs.orElse(null));
+        if (args != null) {
             try {
                 cache.initConfig(
                     args.getDefaultConfig(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@clients/java/openfeature-provider/src/main/java/io/juspay/superposition/openfeature/SuperpositionOpenFeatureProvider.java`
around lines 342 - 358, reinitConfigCache() currently returns without seeding
the cache when configRefresh.getOutput() is empty, so ensure we fall back to the
provided fallback config: if out.isEmpty() and a fallbackConfig (or the
previously used fallbackArgs) is present, call cache.initConfig(...) with the
fallback default config, contexts, overrides and dimensions, then complete
cacheReady and log the reinitialization; keep the existing try/catch and error
logging around cache.initConfig() so behavior on exceptions remains unchanged.

}

private void reinitExperimentsCache() {
if (expRefresh.isPresent() && expGroupRefresh.isPresent()) {
var exps = expRefresh.get().getOutput();
var groups = expGroupRefresh.get().getOutput();
if (exps.isPresent() && groups.isPresent()) {
try {
cache.initExperiments(exps.get(), groups.get());
log.debug("Experiments cache re-initialized");
} catch (Exception e) {
log.error("Failed to reinitialize experiments cache: {}", e.getMessage());
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,23 @@ internal class EvaluationArgs {
}
}

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is EvaluationArgs) return false
return defaultConfig == other.defaultConfig &&
contexts == other.contexts &&
overrides == other.overrides &&
dimensions == other.dimensions
}

override fun hashCode(): Int {
var result = defaultConfig.hashCode()
result = 31 * result + contexts.hashCode()
result = 31 * result + overrides.hashCode()
result = 31 * result + dimensions.hashCode()
return result
}

companion object {
private val gson = Gson()

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

@JvmStatic
fun buildQueryData(eContext: EvaluationContext): Map<String, String> {
return toQueryData(eContext)
}

private fun serializeDocument(d: Document): String {
return valueToJsonString(d.asObject())
}
Expand Down
Loading