diff --git a/.gitignore b/.gitignore index d0880452..401a8a43 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,4 @@ /target/ */target/ *.iml -**dependency-reduced-pom.xml -openfeature-provider-local/src/main/resources/wasm/confidence_resolver.wasm \ No newline at end of file +**dependency-reduced-pom.xml \ No newline at end of file diff --git a/confidence-proto/src/main/proto/confidence/wasm/messages.proto b/confidence-proto/src/main/proto/confidence/wasm/messages.proto deleted file mode 100644 index ee99555a..00000000 --- a/confidence-proto/src/main/proto/confidence/wasm/messages.proto +++ /dev/null @@ -1,23 +0,0 @@ -syntax = "proto3"; - -package confidence.wasm; -import "google/protobuf/struct.proto"; -option java_package = "com.spotify.confidence.wasm"; - -message Void {} - -message SetResolverStateRequest { - bytes state = 1; - string account_id = 2; -} - -message Request { - bytes data = 1; -} - -message Response { - oneof result { - bytes data = 1; - string error = 2; - } -} diff --git a/confidence-proto/src/main/proto/confidence/wasm/wasm_api.proto b/confidence-proto/src/main/proto/confidence/wasm/wasm_api.proto deleted file mode 100644 index bad9ae9d..00000000 --- a/confidence-proto/src/main/proto/confidence/wasm/wasm_api.proto +++ /dev/null @@ -1,67 +0,0 @@ -syntax = "proto3"; - -package confidence.flags.resolver.v1; - -import "google/protobuf/struct.proto"; -import "google/protobuf/timestamp.proto"; -import "confidence/flags/types/v1/types.proto"; -import "confidence/flags/resolver/v1/types.proto"; -import "confidence/flags/resolver/v1/api.proto"; - -option java_package = "com.spotify.confidence.flags.resolver.v1"; -option java_multiple_files = true; -option java_outer_classname = "WasmApiProto"; - -message LogMessage { - string message = 1; -} - -message ResolveWithStickyRequest { - ResolveFlagsRequest resolve_request = 1; - - // Context about the materialization required for the resolve - map materializations_per_unit = 2; - - // if a materialization info is missing, we want to return to the caller immediately - bool fail_fast_on_sticky = 3; -} - -message MaterializationMap { - // materialization name to info - map info_map = 1; -} - -message MaterializationInfo { - bool unit_in_info = 1; - map rule_to_variant = 2; -} - -message ResolveWithStickyResponse { - oneof resolve_result { - Success success = 1; - MissingMaterializations missing_materializations = 2; - } - - message Success { - ResolveFlagsResponse response = 1; - repeated MaterializationUpdate updates = 2; - } - - message MissingMaterializations { - repeated MissingMaterializationItem items = 1; - } - - message MissingMaterializationItem { - string unit = 1; - string rule = 2; - string read_materialization = 3; - } - - message MaterializationUpdate { - string unit = 1; - string write_materialization = 2; - string rule = 3; - string variant = 4; - } -} - diff --git a/openfeature-provider-local/README.md b/openfeature-provider-local/README.md deleted file mode 100644 index ca62c8c7..00000000 --- a/openfeature-provider-local/README.md +++ /dev/null @@ -1,101 +0,0 @@ -# Confidence OpenFeature Local Provider - -![Status: Experimental](https://img.shields.io/badge/status-experimental-orange) - -A high-performance OpenFeature provider for [Confidence](https://confidence.spotify.com/) feature flags that evaluates flags locally for minimal latency. - -## Features - -- **Local Resolution**: Evaluates feature flags locally using WebAssembly (WASM) -- **Low Latency**: No network calls during flag evaluation -- **Automatic Sync**: Periodically syncs flag configurations from Confidence -- **Exposure Logging**: Fully supported exposure logging (and other resolve analytics) -- **OpenFeature Compatible**: Works with the standard OpenFeature SDK - -## Installation - -Add this dependency to your `pom.xml`: - -```xml - - com.spotify.confidence - openfeature-provider-local - 0.6.1-SNAPSHOT - -``` - - -## Quick Start - -```java -import com.spotify.confidence.ApiSecret; -import com.spotify.confidence.OpenFeatureLocalResolveProvider; -import dev.openfeature.sdk.OpenFeatureAPI; -import dev.openfeature.sdk.Client; - -// Create API credentials -ApiSecret apiSecret = new ApiSecret("your-client-id", "your-client-secret"); -String clientSecret = "your-application-client-secret"; - -// Create and register the provider -OpenFeatureLocalResolveProvider provider = - new OpenFeatureLocalResolveProvider(apiSecret, clientSecret); -OpenFeatureAPI.getInstance().setProvider(provider); - -// Use OpenFeature client -Client client = OpenFeatureAPI.getInstance().getClient(); -String value = client.getStringValue("my-flag", "default-value"); -``` - -## Configuration - - -### Exposure Logging - -Enable or disable exposure logging: - -```java -// Enable exposure logging (default) -new OpenFeatureLocalResolveProvider(apiSecret, clientSecret, true); - -// Disable exposure logging -new OpenFeatureLocalResolveProvider(apiSecret, clientSecret, false); -``` - -## Credentials - -You need two types of credentials: - -1. **API Secret** (`ApiSecret`): For authenticating with the Confidence API - - Contains `clientId` and `clientSecret` for your Confidence application - -2. **Client Secret** (`String`): For flag resolution authentication - - Application-specific secret for flag evaluation - -Both can be obtained from your Confidence dashboard. - -## Sticky Resolve - -The provider supports **Sticky Resolve** for consistent variant assignments across flag evaluations. This ensures users receive the same variant even when their targeting attributes change, and enables pausing experiment intake. - -**By default, sticky assignments are managed by Confidence servers.** When sticky assignment data is needed, the provider makes a network call to Confidence, which maintains the sticky repository server-side with automatic 90-day TTL management. This is a fully supported production approach that requires no additional setup. - - -Optionally, you can implement a custom `MaterializationRepository` to manage sticky assignments in your own storage (Redis, database, etc.) to eliminate network calls and improve latency: - -```java -// Optional: Custom storage for sticky assignments -MaterializationRepository repository = new RedisMaterializationRepository(jedisPool, "myapp"); -OpenFeatureLocalResolveProvider provider = new OpenFeatureLocalResolveProvider( - apiSecret, - clientSecret, - repository -); -``` - -For detailed information on how sticky resolve works and how to implement custom storage backends, see [STICKY_RESOLVE.md](STICKY_RESOLVE.md). - -## Requirements - -- Java 17+ -- OpenFeature SDK 1.6.1+ \ No newline at end of file diff --git a/openfeature-provider-local/STICKY_RESOLVE.md b/openfeature-provider-local/STICKY_RESOLVE.md deleted file mode 100644 index 1d565954..00000000 --- a/openfeature-provider-local/STICKY_RESOLVE.md +++ /dev/null @@ -1,91 +0,0 @@ -# Sticky Resolve Documentation - -## Overview - -Sticky Resolve ensures users receive the same variant throughout an experiment, even if their targeting attributes change or you pause new assignments. - -**Two main use cases:** -1. **Consistent experience** - User moves countries but keeps the same variant -2. **Pause intake** - Stop new assignments while maintaining existing ones - -**Default behavior:** Sticky assignments are managed by Confidence servers with automatic 90-day TTL. When needed, the provider makes a network call to Confidence. No setup required. - -## How It Works - -### Default: Server-Side Storage (RemoteResolverFallback) - -**Flow:** -1. Local WASM resolver attempts to resolve -2. If sticky data needed → network call to Confidence -3. Confidence checks its sticky repository, returns variant -4. Assignment stored server-side with 90-day TTL (auto-renewed on access) - -**Server-side configuration (in Confidence UI):** -- Optionally skip targeting criteria for sticky assignments -- Pause/resume new entity intake -- Automatic TTL management - -### Custom: Local Storage (MaterializationRepository) - -Implement `MaterializationRepository` to store assignments locally and eliminate network calls. - -**Interface:** -```java -public interface MaterializationRepository extends StickyResolveStrategy { - // Load assignments for a unit (e.g., user ID) - CompletableFuture> loadMaterializedAssignmentsForUnit( - String unit, String materialization); - - // Store new assignments - CompletableFuture storeAssignment( - String unit, Map assignments); -} -``` - -**MaterializationInfo structure:** -```java -record MaterializationInfo( - boolean isUnitInMaterialization, - Map ruleToVariant // rule ID -> variant name -) -``` - -## Implementation Examples - -### In-Memory (Testing/Development) - -[Here is an example](src/test/java/com/spotify/confidence/InMemoryMaterializationRepoExample.java) on how to implement a simple in-memory `MaterializationRepository`. The same approach can be used with other more persistent storages (like Redis or similar) which is highly recommended for production use cases. - -#### Usage - -```java -MaterializationRepository repository = new InMemoryMaterializationRepoExample(); - -OpenFeatureLocalResolveProvider provider = new OpenFeatureLocalResolveProvider( - apiSecret, - clientSecret, - repository -); -``` - -## Best Practices - -1. **Fail gracefully** - Storage errors shouldn't fail flag resolution -2. **Use 90-day TTL** - Match Confidence's default behavior, renew on read -3. **Connection pooling** - Use pools for Redis/DB connections -4. **Monitor metrics** - Track cache hit rate, storage latency, errors -5. **Test both paths** - Missing assignments (cold start) and existing assignments - -## When to Use Custom Storage - -| Strategy | Best For | Trade-offs | -|----------|----------|------------| -| **RemoteResolverFallback** (default) | Most apps | Simple, managed by Confidence. Network calls when needed. | -| **MaterializationRepository** (in-memory) | Single-instance apps, testing | Fast, no network. Lost on restart. | -| **MaterializationRepository** (Redis/DB) | Distributed/Multi instance apps | No network calls. Requires storage infra. | - -**Start with the default.** Only implement custom storage if you need to eliminate network calls or work offline. - -## Additional Resources - -- [Confidence Sticky Assignments Documentation](https://confidence.spotify.com/docs/flags/audience#sticky-assignments) diff --git a/openfeature-provider-local/pom.xml b/openfeature-provider-local/pom.xml deleted file mode 100644 index 88ddba74..00000000 --- a/openfeature-provider-local/pom.xml +++ /dev/null @@ -1,230 +0,0 @@ - - - 4.0.0 - - - com.spotify.confidence - confidence-sdk-java - 0.6.5-SNAPSHOT - - - - Confidence local resolve provider - openfeature-provider-local - - - - 17 - - 17 - 17 - - v0.5.0 - - - - - - - com.spotify.confidence - confidence-proto - 0.6.5-SNAPSHOT - - - com.spotify.confidence - openfeature-provider-shared - 0.6.5-SNAPSHOT - - - - - - org.slf4j - slf4j-api - - - - com.google.guava - guava - - - com.dylibso.chicory - wasm - 1.4.0 - - - io.grpc - grpc-api - - - com.google.protobuf - protobuf-java - - - - io.grpc - grpc-netty-shaded - - - com.google.guava - guava - - - - - - io.grpc - grpc-stub - - - com.google.protobuf - protobuf-java-util - - - - - org.assertj - assertj-core - test - - - org.junit.jupiter - junit-jupiter - test - - - com.dylibso.chicory - runtime - 1.4.0 - - - com.dylibso.chicory - compiler - 1.4.0 - - - org.junit.jupiter - junit-jupiter-api - test - - - org.mockito - mockito-core - test - - - io.grpc - grpc-services - - - net.bytebuddy - byte-buddy - 1.17.6 - test - - - org.mockito - mockito-inline - 5.2.0 - test - - - net.bytebuddy - byte-buddy - - - - - com.github.ben-manes.caffeine - caffeine - 3.2.0 - - - org.apache.commons - commons-lang3 - 3.17.0 - test - - - io.dropwizard.metrics - metrics-core - 4.2.32 - - - com.auth0 - java-jwt - 4.5.0 - - - dev.openfeature - sdk - 1.6.1 - - - com.spotify - completable-futures - 0.3.6 - - - - - - - - org.apache.maven.plugins - maven-clean-plugin - 3.3.2 - - - - src/main/resources/wasm - - confidence_resolver.wasm - - - - - - - - - org.apache.maven.plugins - maven-antrun-plugin - 3.1.0 - - - download-wasm - generate-resources - - run - - - - - - - - - - - - - - - src/main/resources - true - - **/*.wasm - - - - src/main/resources - false - - **/*.wasm - - - - - - \ No newline at end of file diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/AccountClient.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/AccountClient.java deleted file mode 100644 index f6801478..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/AccountClient.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.spotify.confidence; - -import com.spotify.confidence.shaded.iam.v1.Client; -import com.spotify.confidence.shaded.iam.v1.ClientCredential; - -record AccountClient(String accountName, Client client, ClientCredential clientCredential) {} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/AccountStateProvider.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/AccountStateProvider.java deleted file mode 100644 index 2fdd0d3e..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/AccountStateProvider.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.spotify.confidence; - -/** - * Functional interface for providing AccountState instances. - * - *

The untyped nature of this interface allows high flexibility for testing, but it's not advised - * to be used in production. - * - *

This can be useful if the provider implementer defines the AccountState proto schema in a - * different Java package. - */ -@FunctionalInterface -public interface AccountStateProvider { - - /** - * Provides an AccountState protobuf, from this proto specification: {@link - * com.spotify.confidence.shaded.flags.admin.v1.AccountState} - * - * @return the AccountState protobuf containing flag configurations and metadata - * @throws RuntimeException if the AccountState cannot be provided - */ - byte[] provide(); -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/ApiSecret.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/ApiSecret.java deleted file mode 100644 index 79214fd1..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/ApiSecret.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.spotify.confidence; - -/** - * API credentials for authenticating with the Confidence service. - * - *

This record holds the client ID and client secret used to authenticate with the Confidence API - * for administrative operations like fetching flag configurations and logging exposure events. - * - * @param clientId the client ID for your Confidence application - * @param clientSecret the client secret for your Confidence application - * @since 0.2.4 - */ -public record ApiSecret(String clientId, String clientSecret) {} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/AssignLogger.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/AssignLogger.java deleted file mode 100644 index 98ddb9ad..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/AssignLogger.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.spotify.confidence; - -import static com.google.protobuf.CodedOutputStream.computeMessageSize; - -import com.codahale.metrics.Gauge; -import com.codahale.metrics.Meter; -import com.codahale.metrics.MetricRegistry; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.collect.ImmutableList; -import com.spotify.confidence.shaded.flags.resolver.v1.InternalFlagLoggerServiceGrpc; -import com.spotify.confidence.shaded.flags.resolver.v1.Sdk; -import com.spotify.confidence.shaded.flags.resolver.v1.TelemetryData; -import com.spotify.confidence.shaded.flags.resolver.v1.WriteFlagAssignedRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.WriteFlagAssignedResponse; -import com.spotify.confidence.shaded.flags.resolver.v1.events.FlagAssigned; -import java.io.Closeable; -import java.time.Duration; -import java.time.Instant; -import java.util.Collection; -import java.util.List; -import java.util.Timer; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.LongAdder; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -class AssignLogger implements Closeable { - private static final Logger logger = LoggerFactory.getLogger(AssignLogger.class); - - // Max size minus some wiggle room - private static final int GRPC_MESSAGE_MAX_SIZE = 4194304 - 1048576; - - private final ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>(); - private final LongAdder dropCount = new LongAdder(); - private final AtomicLong capacity; - private Instant lastFlagAssigned = Instant.now(); - private final Timer timer; - - private final InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub flagLoggerStub; - private final Meter assigned; - - @VisibleForTesting - AssignLogger( - InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub flagLoggerStub, - Timer timer, - MetricRegistry metricRegistry, - long capacity) { - this.timer = timer; - this.flagLoggerStub = flagLoggerStub; - this.capacity = new AtomicLong(capacity); - this.assigned = metricRegistry.meter("assign-logger.applies"); - metricRegistry.register( - "assign-logger.ms-since-last-sync", (Gauge) this::timeSinceLastAssigned); - metricRegistry.register( - "assign-logger.occupancy_ratio", - (Gauge) () -> 1.0 - (double) remainingCapacity() / capacity); - } - - private Long timeSinceLastAssigned() { - return Duration.between(lastFlagAssigned, Instant.now()).toMillis(); - } - - @VisibleForTesting - Collection queuedAssigns() { - return ImmutableList.copyOf(queue); - } - - @VisibleForTesting - synchronized void checkpoint() { - WriteFlagAssignedRequest.Builder batch = prepareNewBatch(); - int batchSize = batch.build().getSerializedSize(); - FlagAssigned assigned = queue.peek(); - while (assigned != null) { - final int size = - computeMessageSize(WriteFlagAssignedRequest.FLAG_ASSIGNED_FIELD_NUMBER, assigned); - if (batchSize + size > GRPC_MESSAGE_MAX_SIZE) { - sendBatch(batch.build()); - batch = prepareNewBatch(); - batchSize = batch.build().getSerializedSize(); - } - batch.addFlagAssigned(queue.poll()); - batchSize += size; - assigned = queue.peek(); - } - if (!batch.getFlagAssignedList().isEmpty()) { - sendBatch(batch.build()); - } - } - - private WriteFlagAssignedRequest.Builder prepareNewBatch() { - return WriteFlagAssignedRequest.newBuilder() - .setTelemetryData( - TelemetryData.newBuilder().setDroppedEvents(dropCount.sumThenReset()).build()); - } - - private void sendBatch(WriteFlagAssignedRequest batch) { - try { - final WriteFlagAssignedResponse response = flagLoggerStub.writeFlagAssigned(batch); - // return the capacity on successful send - capacity.getAndAdd( - batch.getFlagAssignedList().stream().mapToLong(FlagAssigned::getSerializedSize).sum()); - this.assigned.mark(response.getAssignedFlags()); - lastFlagAssigned = Instant.now(); - } catch (RuntimeException ex) { - logger.error( - "Could not send assigns, putting {} back on the queue", - batch.getFlagAssignedList().size(), - ex); - // we still own the capacity so can add back directly to queue - queue.addAll(batch.getFlagAssignedList()); - dropCount.add(batch.getTelemetryData().getDroppedEvents()); - throw ex; - } - } - - @VisibleForTesting - long remainingCapacity() { - return capacity.get(); - } - - @VisibleForTesting - long dropCount() { - return dropCount.sum(); - } - - void logAssigns( - String resolveId, Sdk sdk, List flagsToApply, AccountClient accountClient) { - logAssigns(FlagLogger.createFlagAssigned(resolveId, sdk, flagsToApply, accountClient)); - } - - void logAssigns(FlagAssigned assigned) { - final int size = assigned.getSerializedSize(); - for (long c = capacity.get(); size <= c; c = capacity.get()) { - if (capacity.compareAndSet(c, c - size)) { - queue.add(assigned); - return; - } - } - dropCount.increment(); - } - - @Override - public void close() { - timer.cancel(); - checkpoint(); - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/ConfidenceGrpcFlagResolver.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/ConfidenceGrpcFlagResolver.java deleted file mode 100644 index e0e2e787..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/ConfidenceGrpcFlagResolver.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.spotify.confidence; - -import com.google.protobuf.Struct; -import com.spotify.confidence.shaded.flags.resolver.v1.FlagResolverServiceGrpc; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsResponse; -import com.spotify.confidence.shaded.flags.resolver.v1.Sdk; -import com.spotify.confidence.shaded.flags.resolver.v1.Sdk.Builder; -import com.spotify.confidence.shaded.flags.resolver.v1.SdkId; -import io.grpc.ManagedChannel; -import io.grpc.ManagedChannelBuilder; -import java.time.Duration; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; - -/** - * A simplified gRPC-based flag resolver for fallback scenarios in the local provider. This is a - * copy of the core functionality from GrpcFlagResolver adapted for the local provider's needs. - */ -public class ConfidenceGrpcFlagResolver { - private final ManagedChannel channel; - private final Builder sdkBuilder = - Sdk.newBuilder().setVersion("0.2.8"); // Using static version for local provider - - private final FlagResolverServiceGrpc.FlagResolverServiceFutureStub stub; - - public ConfidenceGrpcFlagResolver() { - final String confidenceDomain = - Optional.ofNullable(System.getenv("CONFIDENCE_DOMAIN")).orElse("edge-grpc.spotify.com"); - final boolean useGrpcPlaintext = - Optional.ofNullable(System.getenv("CONFIDENCE_GRPC_PLAINTEXT")) - .map(Boolean::parseBoolean) - .orElse(false); - - ManagedChannelBuilder builder = ManagedChannelBuilder.forTarget(confidenceDomain); - if (useGrpcPlaintext) { - builder = builder.usePlaintext(); - } - - final ManagedChannel channel = - builder.intercept(new DefaultDeadlineClientInterceptor(Duration.ofMinutes(1))).build(); - - this.channel = channel; - this.stub = FlagResolverServiceGrpc.newFutureStub(channel); - } - - public CompletableFuture resolve( - List flags, String clientSecret, Struct context) { - return GrpcUtil.toCompletableFuture( - stub.withDeadlineAfter(10_000, TimeUnit.MILLISECONDS) - .resolveFlags( - ResolveFlagsRequest.newBuilder() - .setClientSecret(clientSecret) - .addAllFlags(flags) - .setEvaluationContext(context) - .setSdk(sdkBuilder.setId(SdkId.SDK_ID_JAVA_PROVIDER).build()) - .setApply(true) - .build())); - } - - public void close() { - channel.shutdownNow(); - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/DefaultDeadlineClientInterceptor.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/DefaultDeadlineClientInterceptor.java deleted file mode 100644 index 4781eec5..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/DefaultDeadlineClientInterceptor.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2017, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -// Imported from https://github.com/salesforce/grpc-java-contrib - -package com.spotify.confidence; - -import static com.google.common.base.Preconditions.checkArgument; -import static com.google.common.base.Preconditions.checkNotNull; - -import io.grpc.CallOptions; -import io.grpc.Channel; -import io.grpc.ClientCall; -import io.grpc.ClientInterceptor; -import io.grpc.Context; -import io.grpc.ForwardingClientCall; -import io.grpc.MethodDescriptor; -import java.time.Duration; -import java.util.concurrent.TimeUnit; - -class DefaultDeadlineClientInterceptor implements ClientInterceptor { - - private final Duration duration; - - DefaultDeadlineClientInterceptor(Duration duration) { - checkNotNull(duration, "duration"); - checkArgument(!duration.isNegative(), "duration must be greater than zero"); - - this.duration = duration; - } - - @Override - public ClientCall interceptCall( - MethodDescriptor method, CallOptions callOptions, Channel next) { - // Only add a deadline if no other deadline has been set. - if (callOptions.getDeadline() == null && Context.current().getDeadline() == null) { - callOptions = callOptions.withDeadlineAfter(duration.toMillis(), TimeUnit.MILLISECONDS); - } - - return new ForwardingClientCall.SimpleForwardingClientCall( - next.newCall(method, callOptions)) {}; - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/Experimental.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/Experimental.java deleted file mode 100644 index 82c12bc5..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/Experimental.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.spotify.confidence; - -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Documented -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.TYPE) -public @interface Experimental { - String value() default - "This class is experimental and not stable," - + " please avoid using it before talking to the confidence team."; -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/FlagLogger.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/FlagLogger.java deleted file mode 100644 index 447feca2..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/FlagLogger.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.spotify.confidence; - -import com.google.protobuf.Timestamp; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveReason; -import com.spotify.confidence.shaded.flags.resolver.v1.Sdk; -import com.spotify.confidence.shaded.flags.resolver.v1.events.ClientInfo; -import com.spotify.confidence.shaded.flags.resolver.v1.events.FlagAssigned; -import com.spotify.confidence.shaded.flags.resolver.v1.events.FlagAssigned.DefaultAssignment.DefaultAssignmentReason; -import java.util.List; - -interface FlagLogger { - - static FlagAssigned createFlagAssigned( - String resolveId, Sdk sdk, List flagsToApply, AccountClient accountClient) { - final var clientInfo = - ClientInfo.newBuilder() - .setClient(accountClient.client().getName()) - .setClientCredential(accountClient.clientCredential().getName()) - .setSdk(sdk) - .build(); - - final var builder = FlagAssigned.newBuilder().setResolveId(resolveId).setClientInfo(clientInfo); - for (var flag : flagsToApply) { - final var assignedFlag = flag.assignment(); - final FlagAssigned.AppliedFlag.Builder assignedBuilder = - FlagAssigned.AppliedFlag.newBuilder() - .setAssignmentId(assignedFlag.getAssignmentId()) - .setFlag(assignedFlag.getFlag()) - .setApplyTime( - Timestamp.newBuilder() - .setSeconds(flag.skewAdjustedAppliedTime().getEpochSecond()) - .setNanos(flag.skewAdjustedAppliedTime().getNano()) - .build()) - .setTargetingKey(assignedFlag.getTargetingKey()) - .setTargetingKeySelector(assignedFlag.getTargetingKeySelector()) - .setRule(assignedFlag.getRule()) - .addAllFallthroughAssignments(assignedFlag.getFallthroughAssignmentsList()); - - if (!assignedFlag.getVariant().isBlank()) { - assignedBuilder.setAssignmentInfo( - FlagAssigned.AssignmentInfo.newBuilder() - .setSegment(assignedFlag.getSegment()) - .setVariant(assignedFlag.getVariant()) - .build()); - } else { - assignedBuilder.setDefaultAssignment( - FlagAssigned.DefaultAssignment.newBuilder() - .setReason(resolveToAssignmentReason(assignedFlag.getReason())) - .build()); - } - builder.addFlags(assignedBuilder); - } - - return builder.build(); - } - - @SuppressWarnings("deprecation") - private static DefaultAssignmentReason resolveToAssignmentReason(ResolveReason reason) { - return switch (reason) { - case RESOLVE_REASON_NO_SEGMENT_MATCH -> DefaultAssignmentReason.NO_SEGMENT_MATCH; - case RESOLVE_REASON_NO_TREATMENT_MATCH -> DefaultAssignmentReason.NO_TREATMENT_MATCH; - case RESOLVE_REASON_FLAG_ARCHIVED -> DefaultAssignmentReason.FLAG_ARCHIVED; - default -> DefaultAssignmentReason.DEFAULT_ASSIGNMENT_REASON_UNSPECIFIED; - }; - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/FlagResolverService.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/FlagResolverService.java deleted file mode 100644 index aa94cdf7..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/FlagResolverService.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.spotify.confidence; - -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsResponse; -import java.util.concurrent.CompletableFuture; - -interface FlagResolverService { - CompletableFuture resolveFlags(ResolveFlagsRequest request); - - void close(); -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/FlagToApply.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/FlagToApply.java deleted file mode 100644 index 4a616e0d..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/FlagToApply.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.spotify.confidence; - -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveTokenV1; -import java.time.Instant; - -record FlagToApply(Instant skewAdjustedAppliedTime, ResolveTokenV1.AssignedFlag assignment) {} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/FlagsAdminStateFetcher.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/FlagsAdminStateFetcher.java deleted file mode 100644 index 2d327032..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/FlagsAdminStateFetcher.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.spotify.confidence; - -import com.google.protobuf.Timestamp; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolverStateServiceGrpc; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolverStateUriRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolverStateUriResponse; -import io.grpc.health.v1.HealthCheckResponse; -import java.io.IOException; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.time.Duration; -import java.time.Instant; -import java.util.concurrent.atomic.AtomicReference; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -class FlagsAdminStateFetcher { - - private static final Logger logger = LoggerFactory.getLogger(FlagsAdminStateFetcher.class); - - private final ResolverStateServiceGrpc.ResolverStateServiceBlockingStub resolverStateService; - private final HealthStatus healthStatus; - private final String accountName; - // ETag for conditional GETs of resolver state - private final AtomicReference etagHolder = new AtomicReference<>(); - private final AtomicReference rawResolverStateHolder = - new AtomicReference<>( - com.spotify.confidence.shaded.flags.admin.v1.ResolverState.newBuilder() - .build() - .toByteArray()); - private final AtomicReference resolverStateUriResponse = - new AtomicReference<>(); - private final AtomicReference refreshTimeHolder = new AtomicReference<>(); - String accountId; - - public FlagsAdminStateFetcher( - ResolverStateServiceGrpc.ResolverStateServiceBlockingStub resolverStateService, - HealthStatus healthStatus, - String accountName) { - this.resolverStateService = resolverStateService; - this.healthStatus = healthStatus; - this.accountName = accountName; - } - - public AtomicReference rawStateHolder() { - return rawResolverStateHolder; - } - - public void reload() { - try { - fetchAndUpdateStateIfChanged(); - } catch (Exception e) { - logger.warn("Failed to reload, ignoring reload", e); - } - healthStatus.setStatus(HealthCheckResponse.ServingStatus.SERVING); - } - - private ResolverStateUriResponse getResolverFileUri() { - final Instant now = Instant.now(); - if (resolverStateUriResponse.get() == null - || (refreshTimeHolder.get() == null || refreshTimeHolder.get().isBefore(now))) { - resolverStateUriResponse.set( - resolverStateService.resolverStateUri(ResolverStateUriRequest.getDefaultInstance())); - final var ttl = - Duration.between(now, toInstant(resolverStateUriResponse.get().getExpireTime())); - refreshTimeHolder.set(now.plusMillis(ttl.toMillis() / 2)); - } - return resolverStateUriResponse.get(); - } - - private Instant toInstant(Timestamp time) { - return Instant.ofEpochSecond(time.getSeconds(), time.getNanos()); - } - - private void fetchAndUpdateStateIfChanged() { - final var response = getResolverFileUri(); - this.accountId = response.getAccount(); - final var uri = response.getSignedUri(); - try { - final HttpURLConnection conn = (HttpURLConnection) new URL(uri).openConnection(); - final String previousEtag = etagHolder.get(); - if (previousEtag != null) { - conn.setRequestProperty("if-none-match", previousEtag); - } - if (conn.getResponseCode() == 304) { - // Not modified - return; - } - final String etag = conn.getHeaderField("etag"); - try (final InputStream stream = conn.getInputStream()) { - final byte[] bytes = stream.readAllBytes(); - rawResolverStateHolder.set(bytes); - etagHolder.set(etag); - } - logger.info("Loaded resolver state for {}, etag={}", accountName, etag); - } catch (IOException e) { - throw new RuntimeException(e); - } - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcUtil.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcUtil.java deleted file mode 100644 index c18e3e05..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcUtil.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.spotify.confidence; - -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.MoreExecutors; -import io.grpc.ManagedChannel; -import io.grpc.ManagedChannelBuilder; -import java.time.Duration; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; - -/** - * Utility class for converting gRPC ListenableFuture to Java CompletableFuture. Copied from the - * main SDK to avoid dependencies. - */ -final class GrpcUtil { - - private static final String CONFIDENCE_DOMAIN = "edge-grpc.spotify.com"; - - private GrpcUtil() {} - - static CompletableFuture toCompletableFuture(final ListenableFuture listenableFuture) { - final CompletableFuture completableFuture = - new CompletableFuture<>() { - @Override - public boolean cancel(boolean mayInterruptIfRunning) { - listenableFuture.cancel(mayInterruptIfRunning); - return super.cancel(mayInterruptIfRunning); - } - }; - Futures.addCallback( - listenableFuture, - new FutureCallback() { - @Override - public void onSuccess(T result) { - completableFuture.complete(result); - } - - @Override - public void onFailure(Throwable t) { - completableFuture.completeExceptionally(t); - } - }, - MoreExecutors.directExecutor()); - return completableFuture; - } - - static ManagedChannel createConfidenceChannel() { - final String confidenceDomain = - Optional.ofNullable(System.getenv("CONFIDENCE_DOMAIN")).orElse(CONFIDENCE_DOMAIN); - final boolean useGrpcPlaintext = - Optional.ofNullable(System.getenv("CONFIDENCE_GRPC_PLAINTEXT")) - .map(Boolean::parseBoolean) - .orElse(false); - ManagedChannelBuilder builder = ManagedChannelBuilder.forTarget(confidenceDomain); - if (useGrpcPlaintext) { - builder = builder.usePlaintext(); - } - return builder.intercept(new DefaultDeadlineClientInterceptor(Duration.ofMinutes(1))).build(); - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcWasmFlagLogger.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcWasmFlagLogger.java deleted file mode 100644 index 8293c837..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcWasmFlagLogger.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.spotify.confidence; - -import static com.spotify.confidence.GrpcUtil.createConfidenceChannel; - -import com.google.common.annotations.VisibleForTesting; -import com.spotify.confidence.shaded.flags.resolver.v1.InternalFlagLoggerServiceGrpc; -import com.spotify.confidence.shaded.flags.resolver.v1.WriteFlagLogsRequest; -import com.spotify.confidence.shaded.iam.v1.AuthServiceGrpc; -import io.grpc.Channel; -import io.grpc.ClientInterceptors; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@FunctionalInterface -interface FlagLogWriter { - void write(WriteFlagLogsRequest request); -} - -public class GrpcWasmFlagLogger implements WasmFlagLogger { - private static final Logger logger = LoggerFactory.getLogger(GrpcWasmFlagLogger.class); - // Max number of flag_assigned entries per chunk to avoid exceeding gRPC max message size - private static final int MAX_FLAG_ASSIGNED_PER_CHUNK = 1000; - private final InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub stub; - private final ExecutorService executorService; - private final FlagLogWriter writer; - - @VisibleForTesting - public GrpcWasmFlagLogger(ApiSecret apiSecret, FlagLogWriter writer) { - final var channel = createConfidenceChannel(); - final AuthServiceGrpc.AuthServiceBlockingStub authService = - AuthServiceGrpc.newBlockingStub(channel); - final TokenHolder tokenHolder = - new TokenHolder(apiSecret.clientId(), apiSecret.clientSecret(), authService); - final Channel authenticatedChannel = - ClientInterceptors.intercept(channel, new JwtAuthClientInterceptor(tokenHolder)); - this.stub = InternalFlagLoggerServiceGrpc.newBlockingStub(authenticatedChannel); - this.executorService = Executors.newCachedThreadPool(); - this.writer = writer; - } - - public GrpcWasmFlagLogger(ApiSecret apiSecret) { - final var channel = createConfidenceChannel(); - final AuthServiceGrpc.AuthServiceBlockingStub authService = - AuthServiceGrpc.newBlockingStub(channel); - final TokenHolder tokenHolder = - new TokenHolder(apiSecret.clientId(), apiSecret.clientSecret(), authService); - final Channel authenticatedChannel = - ClientInterceptors.intercept(channel, new JwtAuthClientInterceptor(tokenHolder)); - this.stub = InternalFlagLoggerServiceGrpc.newBlockingStub(authenticatedChannel); - this.executorService = Executors.newCachedThreadPool(); - this.writer = - request -> - executorService.submit( - () -> { - try { - final var ignore = stub.writeFlagLogs(request); - logger.debug( - "Successfully sent flag log with {} entries", - request.getFlagAssignedCount()); - } catch (Exception e) { - logger.error("Failed to write flag logs", e); - } - }); - } - - @Override - public void write(WriteFlagLogsRequest request) { - if (request.getClientResolveInfoList().isEmpty() - && request.getFlagAssignedList().isEmpty() - && request.getFlagResolveInfoList().isEmpty()) { - logger.debug("Skipping empty flag log request"); - return; - } - - final int flagAssignedCount = request.getFlagAssignedCount(); - - // If flag_assigned list is small enough, send everything as-is - if (flagAssignedCount <= MAX_FLAG_ASSIGNED_PER_CHUNK) { - sendAsync(request); - return; - } - - // Split flag_assigned into chunks and send each chunk asynchronously - logger.debug( - "Splitting {} flag_assigned entries into chunks of {}", - flagAssignedCount, - MAX_FLAG_ASSIGNED_PER_CHUNK); - - final List chunks = createFlagAssignedChunks(request); - for (WriteFlagLogsRequest chunk : chunks) { - sendAsync(chunk); - } - } - - private List createFlagAssignedChunks(WriteFlagLogsRequest request) { - final List chunks = new ArrayList<>(); - final int totalFlags = request.getFlagAssignedCount(); - - for (int i = 0; i < totalFlags; i += MAX_FLAG_ASSIGNED_PER_CHUNK) { - final int end = Math.min(i + MAX_FLAG_ASSIGNED_PER_CHUNK, totalFlags); - final WriteFlagLogsRequest.Builder chunkBuilder = - WriteFlagLogsRequest.newBuilder() - .addAllFlagAssigned(request.getFlagAssignedList().subList(i, end)); - - // Include telemetry and resolve info only in the first chunk - if (i == 0) { - if (request.hasTelemetryData()) { - chunkBuilder.setTelemetryData(request.getTelemetryData()); - } - chunkBuilder - .addAllClientResolveInfo(request.getClientResolveInfoList()) - .addAllFlagResolveInfo(request.getFlagResolveInfoList()); - } - - chunks.add(chunkBuilder.build()); - } - - return chunks; - } - - private void sendAsync(WriteFlagLogsRequest request) { - writer.write(request); - } - - /** - * Shutdown the executor service. This will allow any pending async writes to complete. Call this - * when the application is shutting down. - */ - @Override - public void shutdown() { - executorService.shutdown(); - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/HealthStatus.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/HealthStatus.java deleted file mode 100644 index 1ddcfb9d..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/HealthStatus.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.spotify.confidence; - -import static io.grpc.protobuf.services.HealthStatusManager.SERVICE_NAME_ALL_SERVICES; - -import io.grpc.health.v1.HealthCheckResponse; -import io.grpc.protobuf.services.HealthStatusManager; -import java.util.concurrent.atomic.AtomicReference; - -class HealthStatus { - - private final HealthStatusManager healthStatusManager; - private final AtomicReference status = - new AtomicReference<>(HealthCheckResponse.ServingStatus.NOT_SERVING); - - HealthStatus(HealthStatusManager healthStatusManager) { - this.healthStatusManager = healthStatusManager; - healthStatusManager.setStatus(SERVICE_NAME_ALL_SERVICES, status.get()); - } - - synchronized void setStatus(HealthCheckResponse.ServingStatus status) { - this.status.set(status); - healthStatusManager.setStatus(SERVICE_NAME_ALL_SERVICES, status); - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/IsClosedException.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/IsClosedException.java deleted file mode 100644 index a432c930..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/IsClosedException.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.spotify.confidence; - -class IsClosedException extends Exception {} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/JwtAuthClientInterceptor.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/JwtAuthClientInterceptor.java deleted file mode 100644 index 97f60996..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/JwtAuthClientInterceptor.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.spotify.confidence; - -import static com.spotify.confidence.JwtUtils.AUTHORIZATION_METADATA_KEY; -import static com.spotify.confidence.JwtUtils.getTokenAsHeader; - -import io.grpc.CallOptions; -import io.grpc.Channel; -import io.grpc.ClientCall; -import io.grpc.ClientInterceptor; -import io.grpc.ForwardingClientCall; -import io.grpc.Metadata; -import io.grpc.MethodDescriptor; - -/** Client interceptor that attaches a given auth token to all outgoing requests. */ -class JwtAuthClientInterceptor implements ClientInterceptor { - - private final TokenHolder tokenHolder; - - public JwtAuthClientInterceptor(TokenHolder tokenHolder) { - this.tokenHolder = tokenHolder; - } - - @Override - public ClientCall interceptCall( - MethodDescriptor method, CallOptions callOptions, Channel next) { - return new ForwardingClientCall.SimpleForwardingClientCall<>( - next.newCall(method, callOptions)) { - @Override - public void start(final Listener responseListener, final Metadata headers) { - headers.put(AUTHORIZATION_METADATA_KEY, getTokenAsHeader(tokenHolder.getToken().token())); - super.start(responseListener, headers); - } - }; - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/JwtUtils.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/JwtUtils.java deleted file mode 100644 index 58df639a..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/JwtUtils.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.spotify.confidence; - -import static io.grpc.Metadata.ASCII_STRING_MARSHALLER; - -import com.auth0.jwt.exceptions.JWTVerificationException; -import com.auth0.jwt.interfaces.Claim; -import com.auth0.jwt.interfaces.DecodedJWT; -import io.grpc.Metadata; -import java.util.Optional; - -class JwtUtils { - - public static final Metadata.Key AUTHORIZATION_METADATA_KEY = - Metadata.Key.of("Authorization", ASCII_STRING_MARSHALLER); - - public static final String ACCOUNT_NAME_CLAIM = "https://confidence.dev/account_name"; - - public static Claim getClaimOrThrow(final DecodedJWT jwt, final String claim) - throws JWTVerificationException { - - return getClaim(jwt, claim) - .orElseThrow( - () -> - new JWTVerificationException( - String.format("Missing required claim '%s' in JWT.", claim))); - } - - public static Optional getClaim(final DecodedJWT jwt, final String claim) - throws JWTVerificationException { - if (!jwt.getClaims().containsKey(claim)) { - return Optional.empty(); - } else { - return Optional.of(jwt.getClaim(claim)); - } - } - - public static String getTokenAsHeader(String rawToken) { - return String.format("Bearer %s", rawToken); - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/LocalResolverServiceFactory.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/LocalResolverServiceFactory.java deleted file mode 100644 index 2c405fea..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/LocalResolverServiceFactory.java +++ /dev/null @@ -1,145 +0,0 @@ -package com.spotify.confidence; - -import static com.spotify.confidence.GrpcUtil.createConfidenceChannel; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.util.concurrent.ThreadFactoryBuilder; -import com.spotify.confidence.TokenHolder.Token; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolverStateServiceGrpc; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolverStateServiceGrpc.ResolverStateServiceBlockingStub; -import com.spotify.confidence.shaded.flags.resolver.v1.WriteFlagLogsRequest; -import com.spotify.confidence.shaded.iam.v1.AuthServiceGrpc; -import com.spotify.confidence.shaded.iam.v1.AuthServiceGrpc.AuthServiceBlockingStub; -import com.spotify.confidence.shaded.iam.v1.ClientCredential.ClientSecret; -import io.grpc.Channel; -import io.grpc.ClientInterceptors; -import io.grpc.protobuf.services.HealthStatusManager; -import java.time.Duration; -import java.util.Optional; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; - -class LocalResolverServiceFactory implements ResolverServiceFactory { - private final ResolverApi wasmResolveApi; - private static final Duration POLL_LOG_INTERVAL = Duration.ofSeconds(10); - private static final ScheduledExecutorService flagsFetcherExecutor = - Executors.newScheduledThreadPool(1, new ThreadFactoryBuilder().setDaemon(true).build()); - private final StickyResolveStrategy stickyResolveStrategy; - private static final ScheduledExecutorService logPollExecutor = - Executors.newScheduledThreadPool(1, new ThreadFactoryBuilder().setDaemon(true).build()); - - static FlagResolverService from( - ApiSecret apiSecret, StickyResolveStrategy stickyResolveStrategy) { - return createFlagResolverService(apiSecret, stickyResolveStrategy); - } - - static FlagResolverService from( - AccountStateProvider accountStateProvider, - String accountId, - StickyResolveStrategy stickyResolveStrategy) { - return createFlagResolverService(accountStateProvider, accountId, stickyResolveStrategy); - } - - private static FlagResolverService createFlagResolverService( - ApiSecret apiSecret, StickyResolveStrategy stickyResolveStrategy) { - final var channel = createConfidenceChannel(); - final AuthServiceBlockingStub authService = AuthServiceGrpc.newBlockingStub(channel); - final TokenHolder tokenHolder = - new TokenHolder(apiSecret.clientId(), apiSecret.clientSecret(), authService); - final Token token = tokenHolder.getToken(); - final Channel authenticatedChannel = - ClientInterceptors.intercept(channel, new JwtAuthClientInterceptor(tokenHolder)); - final ResolverStateServiceBlockingStub resolverStateService = - ResolverStateServiceGrpc.newBlockingStub(authenticatedChannel); - final HealthStatusManager healthStatusManager = new HealthStatusManager(); - final HealthStatus healthStatus = new HealthStatus(healthStatusManager); - final FlagsAdminStateFetcher sidecarFlagsAdminFetcher = - new FlagsAdminStateFetcher(resolverStateService, healthStatus, token.account()); - final long pollIntervalSeconds = - Optional.ofNullable(System.getenv("CONFIDENCE_RESOLVER_POLL_INTERVAL_SECONDS")) - .map(Long::parseLong) - .orElse(Duration.ofMinutes(5).toSeconds()); - final var wasmFlagLogger = new GrpcWasmFlagLogger(apiSecret); - final ResolverApi wasmResolverApi = - new ThreadLocalSwapWasmResolverApi( - wasmFlagLogger, - sidecarFlagsAdminFetcher.rawStateHolder().get(), - sidecarFlagsAdminFetcher.accountId, - stickyResolveStrategy); - flagsFetcherExecutor.scheduleAtFixedRate( - sidecarFlagsAdminFetcher::reload, - pollIntervalSeconds, - pollIntervalSeconds, - TimeUnit.SECONDS); - - logPollExecutor.scheduleAtFixedRate( - () -> - wasmResolverApi.updateStateAndFlushLogs( - sidecarFlagsAdminFetcher.rawStateHolder().get(), - sidecarFlagsAdminFetcher.accountId), - POLL_LOG_INTERVAL.getSeconds(), - POLL_LOG_INTERVAL.getSeconds(), - TimeUnit.SECONDS); - - return new WasmFlagResolverService(wasmResolverApi, stickyResolveStrategy); - } - - private static FlagResolverService createFlagResolverService( - AccountStateProvider accountStateProvider, - String accountId, - StickyResolveStrategy stickyResolveStrategy) { - final var mode = System.getenv("LOCAL_RESOLVE_MODE"); - if (!(mode == null || mode.equals("WASM"))) { - throw new RuntimeException("Only WASM mode supported with AccountStateProvider"); - } - final long pollIntervalSeconds = - Optional.ofNullable(System.getenv("CONFIDENCE_RESOLVER_POLL_INTERVAL_SECONDS")) - .map(Long::parseLong) - .orElse(Duration.ofMinutes(5).toSeconds()); - final AtomicReference resolverStateProtobuf = - new AtomicReference<>(accountStateProvider.provide()); - // No-op logger for wasm mode with AccountStateProvider - final WasmFlagLogger flagLogger = - new WasmFlagLogger() { - @Override - public void write(WriteFlagLogsRequest request) {} - - @Override - public void shutdown() {} - }; - final ResolverApi wasmResolverApi = - new ThreadLocalSwapWasmResolverApi( - flagLogger, resolverStateProtobuf.get(), accountId, stickyResolveStrategy); - flagsFetcherExecutor.scheduleAtFixedRate( - () -> resolverStateProtobuf.set(accountStateProvider.provide()), - pollIntervalSeconds, - pollIntervalSeconds, - TimeUnit.SECONDS); - logPollExecutor.scheduleAtFixedRate( - () -> wasmResolverApi.updateStateAndFlushLogs(resolverStateProtobuf.get(), accountId), - POLL_LOG_INTERVAL.getSeconds(), - POLL_LOG_INTERVAL.getSeconds(), - TimeUnit.SECONDS); - return new WasmFlagResolverService(wasmResolverApi, stickyResolveStrategy); - } - - LocalResolverServiceFactory( - ResolverApi wasmResolveApi, StickyResolveStrategy stickyResolveStrategy) { - this.wasmResolveApi = wasmResolveApi; - this.stickyResolveStrategy = stickyResolveStrategy; - } - - @VisibleForTesting - public void setState(byte[] state, String accountId) { - if (this.wasmResolveApi != null) { - wasmResolveApi.updateStateAndFlushLogs(state, accountId); - } - } - - @Override - public FlagResolverService create(ClientSecret clientSecret) { - return new WasmFlagResolverService(wasmResolveApi, stickyResolveStrategy); - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/MaterializationInfo.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/MaterializationInfo.java deleted file mode 100644 index 09793cbb..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/MaterializationInfo.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.spotify.confidence; - -import java.util.Map; - -public record MaterializationInfo( - boolean isUnitInMaterialization, Map ruleToVariant) { - - public com.spotify.confidence.flags.resolver.v1.MaterializationInfo toProto() { - return com.spotify.confidence.flags.resolver.v1.MaterializationInfo.newBuilder() - .setUnitInInfo(this.isUnitInMaterialization) - .putAllRuleToVariant(this.ruleToVariant) - .build(); - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/MaterializationRepository.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/MaterializationRepository.java deleted file mode 100644 index 8813871e..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/MaterializationRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.spotify.confidence; - -import java.util.Map; -import java.util.concurrent.CompletableFuture; - -public non-sealed interface MaterializationRepository extends StickyResolveStrategy { - CompletableFuture> loadMaterializedAssignmentsForUnit( - String unit, String materialization); - - CompletableFuture storeAssignment( - String unit, Map assignments); -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/OpenFeatureLocalResolveProvider.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/OpenFeatureLocalResolveProvider.java deleted file mode 100644 index 199976a2..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/OpenFeatureLocalResolveProvider.java +++ /dev/null @@ -1,291 +0,0 @@ -package com.spotify.confidence; - -import com.google.common.annotations.VisibleForTesting; -import com.google.protobuf.Struct; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsResponse; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolvedFlag; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.FeatureProvider; -import dev.openfeature.sdk.Metadata; -import dev.openfeature.sdk.ProviderEvaluation; -import dev.openfeature.sdk.Value; -import dev.openfeature.sdk.exceptions.FlagNotFoundError; -import dev.openfeature.sdk.exceptions.GeneralError; -import dev.openfeature.sdk.exceptions.TypeMismatchError; -import io.grpc.Status; -import io.grpc.StatusRuntimeException; -import java.util.concurrent.ExecutionException; -import java.util.function.Function; -import org.slf4j.Logger; - -/** - * OpenFeature provider for Confidence feature flags using local resolution. - * - *

This provider evaluates feature flags locally using either a WebAssembly (WASM) resolver. It - * periodically syncs flag configurations from the Confidence service and caches them locally for - * fast, low-latency flag evaluation. - * - *

Usage Example: - * - *

{@code
- * // Create API credentials
- * ApiSecret apiSecret = new ApiSecret("your-client-id", "your-client-secret");
- * String clientSecret = "your-application-client-secret";
- *
- * // Create provider with default settings (exposure logs enabled)
- * OpenFeatureLocalResolveProvider provider =
- *     new OpenFeatureLocalResolveProvider(apiSecret, clientSecret);
- *
- * // Register with OpenFeature
- * OpenFeatureAPI.getInstance().setProvider(provider);
- *
- * // Use with OpenFeature client
- * Client client = OpenFeatureAPI.getInstance().getClient();
- * String flagValue = client.getStringValue("my-flag", "default-value");
- * }
- * - * @since 0.2.4 - */ -@Experimental -public class OpenFeatureLocalResolveProvider implements FeatureProvider { - private final String clientSecret; - private static final Logger log = - org.slf4j.LoggerFactory.getLogger(OpenFeatureLocalResolveProvider.class); - private final FlagResolverService flagResolverService; - private final StickyResolveStrategy stickyResolveStrategy; - - /** - * Creates a new OpenFeature provider for local flag resolution with sticky default fallback - * strategy and no retry. - * - *

This constructor uses {@link RemoteResolverFallback} as the default sticky resolve strategy, - * which provides fallback to the remote Confidence service when the WASM resolver encounters - * missing materializations. By default, no retry strategy is applied. - * - * @param apiSecret the API credentials containing client ID and client secret for authenticating - * with the Confidence service. Create using {@code new ApiSecret("client-id", - * "client-secret")} - * @param clientSecret the client secret for your application, used for flag resolution - * authentication. This is different from the API secret and is specific to your application - * configuration - * @since 0.2.4 - */ - public OpenFeatureLocalResolveProvider(ApiSecret apiSecret, String clientSecret) { - this(apiSecret, clientSecret, new RemoteResolverFallback()); - } - - /** - * Creates a new OpenFeature provider for local flag resolution with full configuration control. - * - *

This is the primary constructor that allows full control over the provider configuration, - * including retry strategy. - * - * @param apiSecret the API credentials containing client ID and client secret for authenticating - * with the Confidence service. Create using {@code new ApiSecret("client-id", - * "client-secret")} - * @param clientSecret the client secret for your application, used for flag resolution - * authentication. This is different from the API secret and is specific to your application - * configuration - * @param stickyResolveStrategy the strategy to use for handling sticky flag resolution - * @since 0.2.4 - */ - public OpenFeatureLocalResolveProvider( - ApiSecret apiSecret, String clientSecret, StickyResolveStrategy stickyResolveStrategy) { - this.flagResolverService = LocalResolverServiceFactory.from(apiSecret, stickyResolveStrategy); - this.clientSecret = clientSecret; - this.stickyResolveStrategy = stickyResolveStrategy; - } - - /** - * To be used for testing purposes only! This constructor allows to inject flags state for testing - * the WASM resolver with full control over retry strategy. - * - * @param accountStateProvider a functional interface that provides AccountState instances - * @param accountId the account ID - * @param clientSecret the flag client key used to filter the flags - * @param stickyResolveStrategy the strategy to use for handling sticky flag resolution - * @since 0.2.4 - */ - @VisibleForTesting - public OpenFeatureLocalResolveProvider( - AccountStateProvider accountStateProvider, - String accountId, - String clientSecret, - StickyResolveStrategy stickyResolveStrategy) { - this.stickyResolveStrategy = stickyResolveStrategy; - this.clientSecret = clientSecret; - this.flagResolverService = - LocalResolverServiceFactory.from(accountStateProvider, accountId, stickyResolveStrategy); - } - - @Override - public Metadata getMetadata() { - return () -> "confidence-sdk-java-local"; - } - - @Override - public ProviderEvaluation getBooleanEvaluation( - String key, Boolean defaultValue, EvaluationContext ctx) { - return getCastedEvaluation(key, defaultValue, ctx, Value::asBoolean); - } - - @Override - public ProviderEvaluation getStringEvaluation( - String key, String defaultValue, EvaluationContext ctx) { - return getCastedEvaluation(key, defaultValue, ctx, Value::asString); - } - - @Override - public ProviderEvaluation getIntegerEvaluation( - String key, Integer defaultValue, EvaluationContext ctx) { - return getCastedEvaluation(key, defaultValue, ctx, Value::asInteger); - } - - @Override - public ProviderEvaluation getDoubleEvaluation( - String key, Double defaultValue, EvaluationContext ctx) { - return getCastedEvaluation(key, defaultValue, ctx, Value::asDouble); - } - - private ProviderEvaluation getCastedEvaluation( - String key, T defaultValue, EvaluationContext ctx, Function cast) { - final Value wrappedDefaultValue; - try { - wrappedDefaultValue = new Value(defaultValue); - } catch (InstantiationException e) { - // this is not going to happen because we only call the constructor with supported types - throw new RuntimeException(e); - } - - final ProviderEvaluation objectEvaluation = - getObjectEvaluation(key, wrappedDefaultValue, ctx); - - final T castedValue = cast.apply(objectEvaluation.getValue()); - if (castedValue == null) { - log.warn("Cannot cast value '{}' to expected type", objectEvaluation.getValue().toString()); - throw new TypeMismatchError( - String.format("Cannot cast value '%s' to expected type", objectEvaluation.getValue())); - } - - return ProviderEvaluation.builder() - .value(castedValue) - .variant(objectEvaluation.getVariant()) - .reason(objectEvaluation.getReason()) - .errorMessage(objectEvaluation.getErrorMessage()) - .errorCode(objectEvaluation.getErrorCode()) - .build(); - } - - @Override - public void shutdown() { - this.stickyResolveStrategy.close(); - this.flagResolverService.close(); - FeatureProvider.super.shutdown(); - } - - @Override - public ProviderEvaluation getObjectEvaluation( - String key, Value defaultValue, EvaluationContext ctx) { - - final FlagPath flagPath; - try { - flagPath = FlagPath.getPath(key); - } catch (Exceptions.IllegalValuePath e) { - log.warn(e.getMessage()); - throw new RuntimeException(e); - } - - final Struct evaluationContext = OpenFeatureUtils.convertToProto(ctx); - // resolve the flag by calling the resolver API - final ResolveFlagsResponse resolveFlagResponse; - try { - final String requestFlagName = "flags/" + flagPath.getFlag(); - - final var req = - ResolveFlagsRequest.newBuilder() - .addFlags(requestFlagName) - .setApply(true) - .setClientSecret(clientSecret) - .setEvaluationContext( - Struct.newBuilder().putAllFields(evaluationContext.getFieldsMap()).build()) - .build(); - - resolveFlagResponse = flagResolverService.resolveFlags(req).get(); - - if (resolveFlagResponse.getResolvedFlagsList().isEmpty()) { - log.warn("No active flag '{}' was found", flagPath.getFlag()); - throw new FlagNotFoundError( - String.format("No active flag '%s' was found", flagPath.getFlag())); - } - - final String responseFlagName = resolveFlagResponse.getResolvedFlags(0).getFlag(); - if (!requestFlagName.equals(responseFlagName)) { - log.warn("Unexpected flag '{}' from remote", responseFlagName.replaceFirst("^flags/", "")); - throw new FlagNotFoundError( - String.format( - "Unexpected flag '%s' from remote", responseFlagName.replaceFirst("^flags/", ""))); - } - - final ResolvedFlag resolvedFlag = resolveFlagResponse.getResolvedFlags(0); - - if (resolvedFlag.getVariant().isEmpty()) { - log.debug( - String.format( - "The server returned no assignment for the flag '%s'. Typically, this happens " - + "if no configured rules matches the given evaluation context.", - flagPath.getFlag())); - return ProviderEvaluation.builder() - .value(defaultValue) - .reason( - "The server returned no assignment for the flag. Typically, this happens " - + "if no configured rules matches the given evaluation context.") - .build(); - } else { - final Value fullValue = - OpenFeatureTypeMapper.from(resolvedFlag.getValue(), resolvedFlag.getFlagSchema()); - - // if a path is given, extract expected portion from the structured value - Value value = OpenFeatureUtils.getValueForPath(flagPath.getPath(), fullValue); - - if (value.isNull()) { - value = defaultValue; - } - - // regular resolve was successful - return ProviderEvaluation.builder() - .value(value) - .reason(resolvedFlag.getReason().toString()) - .variant(resolvedFlag.getVariant()) - .build(); - } - } catch (StatusRuntimeException e) { - handleStatusRuntimeException(e); - throw new GeneralError("Unknown error occurred when calling the provider backend"); - } catch (ExecutionException | InterruptedException e) { - throw new RuntimeException(e); - } - } - - private static void handleStatusRuntimeException(StatusRuntimeException e) { - if (e.getStatus().getCode() == Status.Code.DEADLINE_EXCEEDED) { - log.error("Deadline exceeded when calling provider backend", e); - throw new GeneralError("Deadline exceeded when calling provider backend"); - } else if (e.getStatus().getCode() == Status.Code.UNAVAILABLE) { - log.error("Provider backend is unavailable", e); - throw new GeneralError("Provider backend is unavailable"); - } else if (e.getStatus().getCode() == Status.Code.UNAUTHENTICATED) { - log.error("UNAUTHENTICATED", e); - throw new GeneralError("UNAUTHENTICATED"); - } else { - log.error( - "Unknown error occurred when calling the provider backend. Grpc status code {}", - e.getStatus().getCode(), - e); - throw new GeneralError( - String.format( - "Unknown error occurred when calling the provider backend. Exception: %s", - e.getMessage())); - } - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/RemoteResolverFallback.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/RemoteResolverFallback.java deleted file mode 100644 index 2faacc9f..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/RemoteResolverFallback.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.spotify.confidence; - -import com.google.protobuf.Struct; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsResponse; -import java.util.concurrent.CompletableFuture; - -/** - * A fallback resolver strategy that uses gRPC to resolve flags when the WASM resolver encounters - * missing materializations. This provides a fallback to the remote Confidence service. - */ -final class RemoteResolverFallback implements ResolverFallback, StickyResolveStrategy { - private final ConfidenceGrpcFlagResolver grpcFlagResolver; - - RemoteResolverFallback() { - this.grpcFlagResolver = new ConfidenceGrpcFlagResolver(); - } - - @Override - public CompletableFuture resolve(ResolveFlagsRequest request) { - if (request.getFlagsList().isEmpty()) { - return CompletableFuture.completedFuture(ResolveFlagsResponse.newBuilder().build()); - } - - final Struct context = request.getEvaluationContext(); - - return grpcFlagResolver.resolve(request.getFlagsList(), request.getClientSecret(), context); - } - - /** Closes the underlying gRPC resources. */ - public void close() { - grpcFlagResolver.close(); - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/ResolverApi.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/ResolverApi.java deleted file mode 100644 index 03ecc389..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/ResolverApi.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.spotify.confidence; - -import com.spotify.confidence.flags.resolver.v1.ResolveWithStickyRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsResponse; -import java.util.concurrent.CompletableFuture; - -/** Common interface for WASM-based flag resolver implementations. */ -interface ResolverApi { - - /** - * Resolves flags with sticky assignment support. - * - * @param request The resolve request with sticky context - * @return A future containing the resolve response - */ - CompletableFuture resolveWithSticky(ResolveWithStickyRequest request); - - /** - * Resolves flags without sticky assignment support. - * - * @param request The resolve request - * @return The resolve response - */ - ResolveFlagsResponse resolve(ResolveFlagsRequest request); - - /** - * Updates the resolver state and flushes any pending logs. - * - * @param state The new resolver state - * @param accountId The account ID - */ - void updateStateAndFlushLogs(byte[] state, String accountId); - - /** Closes the resolver and releases any resources. */ - void close(); -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/ResolverFallback.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/ResolverFallback.java deleted file mode 100644 index 18284679..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/ResolverFallback.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.spotify.confidence; - -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsResponse; -import java.util.concurrent.CompletableFuture; - -public non-sealed interface ResolverFallback extends StickyResolveStrategy { - CompletableFuture resolve(ResolveFlagsRequest request); -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/ResolverServiceFactory.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/ResolverServiceFactory.java deleted file mode 100644 index 97e22a95..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/ResolverServiceFactory.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.spotify.confidence; - -import com.spotify.confidence.shaded.iam.v1.ClientCredential; - -interface ResolverServiceFactory { - FlagResolverService create(ClientCredential.ClientSecret clientSecret); - - default FlagResolverService create(String clientSecret) { - return create(ClientCredential.ClientSecret.newBuilder().setSecret(clientSecret).build()); - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/StickyResolveStrategy.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/StickyResolveStrategy.java deleted file mode 100644 index 7893b3d6..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/StickyResolveStrategy.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.spotify.confidence; - -public sealed interface StickyResolveStrategy - permits MaterializationRepository, ResolverFallback, RemoteResolverFallback { - void close(); -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/SwapWasmResolverApi.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/SwapWasmResolverApi.java deleted file mode 100644 index 2615e80b..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/SwapWasmResolverApi.java +++ /dev/null @@ -1,204 +0,0 @@ -package com.spotify.confidence; - -import com.dylibso.chicory.wasm.ChicoryException; -import com.spotify.confidence.flags.resolver.v1.MaterializationMap; -import com.spotify.confidence.flags.resolver.v1.ResolveWithStickyRequest; -import com.spotify.confidence.flags.resolver.v1.ResolveWithStickyResponse; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsResponse; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Collectors; - -class SwapWasmResolverApi implements ResolverApi { - private final AtomicReference wasmResolverApiRef = new AtomicReference<>(); - private final StickyResolveStrategy stickyResolveStrategy; - private final WasmFlagLogger flagLogger; - private final String accountId; - - public SwapWasmResolverApi( - WasmFlagLogger flagLogger, - byte[] initialState, - String accountId, - StickyResolveStrategy stickyResolveStrategy) { - this.stickyResolveStrategy = stickyResolveStrategy; - this.flagLogger = flagLogger; - this.accountId = accountId; - - // Create initial instance - final WasmResolveApi initialInstance = new WasmResolveApi(flagLogger); - initialInstance.setResolverState(initialState, accountId); - this.wasmResolverApiRef.set(initialInstance); - } - - @Override - public void updateStateAndFlushLogs(byte[] state, String accountId) { - // Create new instance with updated state - final WasmResolveApi newInstance = new WasmResolveApi(flagLogger); - if (state != null) { - newInstance.setResolverState(state, accountId); - } - - // Get current instance before switching - final WasmResolveApi oldInstance = wasmResolverApiRef.getAndSet(newInstance); - if (oldInstance != null) { - oldInstance.close(); - } - } - - @Override - public void close() {} - - @Override - public CompletableFuture resolveWithSticky( - ResolveWithStickyRequest request) { - final var instance = wasmResolverApiRef.get(); - final ResolveWithStickyResponse response; - try { - response = instance.resolveWithSticky(request); - } catch (IsClosedException e) { - return resolveWithSticky(request); - } - - switch (response.getResolveResultCase()) { - case SUCCESS -> { - final var success = response.getSuccess(); - // Store updates if present - if (!success.getUpdatesList().isEmpty()) { - storeUpdates(success.getUpdatesList()); - } - return CompletableFuture.completedFuture(success.getResponse()); - } - case MISSING_MATERIALIZATIONS -> { - final var missingMaterializations = response.getMissingMaterializations(); - - // Check for ResolverFallback first - return early if so - if (stickyResolveStrategy instanceof ResolverFallback fallback) { - return fallback.resolve(request.getResolveRequest()); - } - - // Handle MaterializationRepository case - if (stickyResolveStrategy instanceof MaterializationRepository repository) { - final var currentRequest = - handleMissingMaterializations( - request, missingMaterializations.getItemsList(), repository); - return resolveWithSticky(currentRequest); - } - - throw new RuntimeException( - "Unknown sticky resolve strategy: " + stickyResolveStrategy.getClass()); - } - case RESOLVERESULT_NOT_SET -> - throw new RuntimeException("Invalid response: resolve result not set"); - default -> - throw new RuntimeException("Unhandled response case: " + response.getResolveResultCase()); - } - } - - private void storeUpdates(List updates) { - if (stickyResolveStrategy instanceof MaterializationRepository repository) { - CompletableFuture.runAsync( - () -> { - // Group updates by unit - final var updatesByUnit = - updates.stream() - .collect( - Collectors.groupingBy( - ResolveWithStickyResponse.MaterializationUpdate::getUnit)); - - // Store assignments for each unit - updatesByUnit.forEach( - (unit, unitUpdates) -> { - final Map assignments = new HashMap<>(); - unitUpdates.forEach( - update -> { - final var ruleToVariant = Map.of(update.getRule(), update.getVariant()); - assignments.put( - update.getWriteMaterialization(), - new MaterializationInfo(true, ruleToVariant)); - }); - - repository - .storeAssignment(unit, assignments) - .exceptionally( - throwable -> { - // Log error but don't propagate to avoid affecting main resolve path - System.err.println( - "Failed to store materialization updates for unit " - + unit - + ": " - + throwable.getMessage()); - return null; - }); - }); - }); - } - } - - private ResolveWithStickyRequest handleMissingMaterializations( - ResolveWithStickyRequest request, - List missingItems, - MaterializationRepository repository) { - - // Group missing items by unit for efficient loading - final var missingByUnit = - missingItems.stream() - .collect( - Collectors.groupingBy( - ResolveWithStickyResponse.MissingMaterializationItem::getUnit)); - - final HashMap materializationPerUnitMap = new HashMap<>(); - - // Load materialized assignments for all missing units - missingByUnit.forEach( - (unit, materializationInfoItem) -> { - materializationInfoItem.forEach( - item -> { - final Map loadedAssignments; - try { - loadedAssignments = - repository - .loadMaterializedAssignmentsForUnit(unit, item.getReadMaterialization()) - .get(); - } catch (InterruptedException | ExecutionException e) { - throw new RuntimeException(e); - } - materializationPerUnitMap.computeIfAbsent( - unit, - k -> MaterializationMap.newBuilder().putAllInfoMap(new HashMap<>()).build()); - materializationPerUnitMap.computeIfPresent( - unit, - (k, v) -> { - final Map< - String, com.spotify.confidence.flags.resolver.v1.MaterializationInfo> - map = new HashMap<>(); - loadedAssignments.forEach( - (s, materializationInfo) -> { - map.put(s, materializationInfo.toProto()); - }); - return v.toBuilder().putAllInfoMap(map).build(); - }); - }); - }); - - // Return new request with updated materialization context - return request.toBuilder().putAllMaterializationsPerUnit(materializationPerUnitMap).build(); - } - - @Override - public ResolveFlagsResponse resolve(ResolveFlagsRequest request) { - final var instance = wasmResolverApiRef.get(); - try { - return instance.resolve(request); - } catch (IsClosedException e) { - return resolve(request); - } catch (ChicoryException ce) { - updateStateAndFlushLogs(null, accountId); - throw ce; - } - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/ThreadLocalSwapWasmResolverApi.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/ThreadLocalSwapWasmResolverApi.java deleted file mode 100644 index 862562b6..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/ThreadLocalSwapWasmResolverApi.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.spotify.confidence; - -import com.spotify.confidence.flags.resolver.v1.ResolveWithStickyRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsResponse; -import com.spotify.futures.CompletableFutures; -import java.util.ArrayList; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.IntStream; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Pre-initialized resolver instances mapped by thread ID to CPU core count. This eliminates both - * lock contention and lazy initialization overhead. - */ -class ThreadLocalSwapWasmResolverApi implements ResolverApi { - private static final Logger logger = - LoggerFactory.getLogger(ThreadLocalSwapWasmResolverApi.class); - private final WasmFlagLogger flagLogger; - private final StickyResolveStrategy stickyResolveStrategy; - private volatile byte[] currentState; - private volatile String currentAccountId; - - // Pre-initialized resolver instances mapped by core index - private final Map resolverInstances = new ConcurrentHashMap<>(); - private final int numInstances; - private final AtomicInteger nextInstanceIndex = new AtomicInteger(0); - private final ThreadLocal threadInstanceIndex = - new ThreadLocal<>() { - @Override - protected Integer initialValue() { - return nextInstanceIndex.getAndIncrement() % numInstances; - } - }; - - public ThreadLocalSwapWasmResolverApi( - WasmFlagLogger flagLogger, - byte[] initialState, - String accountId, - StickyResolveStrategy stickyResolveStrategy) { - this.flagLogger = flagLogger; - this.stickyResolveStrategy = stickyResolveStrategy; - this.currentState = initialState; - this.currentAccountId = accountId; - - // Pre-create instances based on CPU core count for optimal performance - this.numInstances = Runtime.getRuntime().availableProcessors(); - logger.info( - "Initialized ThreadLocalSwapWasmResolverApi with {} available processors", numInstances); - final var futures = new ArrayList>(numInstances); - - IntStream.range(0, numInstances) - .forEach( - i -> - futures.add( - CompletableFuture.runAsync( - () -> { - final var instance = - new SwapWasmResolverApi( - this.flagLogger, - this.currentState, - this.currentAccountId, - this.stickyResolveStrategy); - resolverInstances.put(i, instance); - }))); - CompletableFutures.allAsList(futures).join(); - } - - /** - * Updates state and flushes logs for all pre-initialized resolver instances. This method is - * typically called by a scheduled task to refresh the resolver state. - */ - @Override - public void updateStateAndFlushLogs(byte[] state, String accountId) { - this.currentState = state; - this.currentAccountId = accountId; - - final var futures = - resolverInstances.values().stream() - .map(v -> CompletableFuture.runAsync(() -> v.updateStateAndFlushLogs(state, accountId))) - .toList(); - CompletableFutures.allAsList(futures).join(); - } - - /** - * Maps the current thread to a resolver instance using round-robin assignment. Each thread gets - * assigned to an instance index when first accessed, ensuring even distribution across available - * instances. - */ - private SwapWasmResolverApi getResolverForCurrentThread() { - final int instanceIndex = threadInstanceIndex.get(); - return resolverInstances.get(instanceIndex); - } - - /** Delegates resolveWithSticky to the assigned SwapWasmResolverApi instance. */ - @Override - public CompletableFuture resolveWithSticky( - ResolveWithStickyRequest request) { - return getResolverForCurrentThread().resolveWithSticky(request); - } - - /** Delegates resolve to the assigned SwapWasmResolverApi instance. */ - @Override - public ResolveFlagsResponse resolve(ResolveFlagsRequest request) { - return getResolverForCurrentThread().resolve(request); - } - - /** Closes all pre-initialized resolver instances and clears the map. */ - @Override - public void close() { - resolverInstances.values().forEach(SwapWasmResolverApi::close); - flagLogger.shutdown(); - resolverInstances.clear(); - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/TokenHolder.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/TokenHolder.java deleted file mode 100644 index 67392b5f..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/TokenHolder.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.spotify.confidence; - -import static com.spotify.confidence.JwtUtils.ACCOUNT_NAME_CLAIM; -import static com.spotify.confidence.JwtUtils.getClaimOrThrow; -import static java.time.Instant.now; - -import com.auth0.jwt.JWT; -import com.auth0.jwt.interfaces.DecodedJWT; -import com.github.benmanes.caffeine.cache.Caffeine; -import com.github.benmanes.caffeine.cache.Expiry; -import com.github.benmanes.caffeine.cache.LoadingCache; -import com.spotify.confidence.shaded.iam.v1.AuthServiceGrpc; -import com.spotify.confidence.shaded.iam.v1.RequestAccessTokenRequest; -import java.time.Duration; -import java.time.Instant; - -class TokenHolder { - - private final String apiClientId; - private final String apiClientSecret; - - private final LoadingCache tokenCache; - private final AuthServiceGrpc.AuthServiceBlockingStub stub; - - public TokenHolder( - String apiClientId, String apiClientSecret, AuthServiceGrpc.AuthServiceBlockingStub stub) { - this.apiClientId = apiClientId; - this.apiClientSecret = apiClientSecret; - this.stub = stub; - - this.tokenCache = - Caffeine.newBuilder() - .expireAfter( - new Expiry() { - @Override - public long expireAfterCreate( - CacheKey cacheKey, Token authToken, long currentTime) { - return getExpiryDuration(authToken); - } - - @Override - public long expireAfterUpdate( - CacheKey cacheKey, Token authToken, long currentTime, long currentDuration) { - return getExpiryDuration(authToken); - } - - @Override - public long expireAfterRead( - CacheKey cacheKey, Token authToken, long currentTime, long currentDuration) { - return currentDuration; - } - - private long getExpiryDuration(Token authToken) { - return Duration.between(now(), authToken.expiration()) - .minusHours(1L) // Subtract an hour to have some margin - .toNanos(); - } - }) - .build(this::requestAccessToken); - } - - public Token getToken() { - return tokenCache.get(new CacheKey()); - } - - private Token requestAccessToken(CacheKey cacheKey) { - final var response = - stub.requestAccessToken( - RequestAccessTokenRequest.newBuilder() - .setClientId(apiClientId) - .setClientSecret(apiClientSecret) - .setGrantType("client_credentials") - .build()); - - final String accessToken = response.getAccessToken(); - final DecodedJWT decodedJWT = JWT.decode(accessToken); - - return new Token( - accessToken, - getClaimOrThrow(decodedJWT, ACCOUNT_NAME_CLAIM).asString(), - now().plusSeconds(response.getExpiresIn())); - } - - public record Token(String token, String account, Instant expiration) {} - - private record CacheKey() {} -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/WasmFlagLogger.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/WasmFlagLogger.java deleted file mode 100644 index dcff465b..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/WasmFlagLogger.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.spotify.confidence; - -import com.spotify.confidence.shaded.flags.resolver.v1.WriteFlagLogsRequest; - -interface WasmFlagLogger { - void write(WriteFlagLogsRequest request); - - void shutdown(); -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/WasmFlagResolverService.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/WasmFlagResolverService.java deleted file mode 100644 index 93fe19e9..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/WasmFlagResolverService.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.spotify.confidence; - -import com.spotify.confidence.flags.resolver.v1.ResolveWithStickyRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsResponse; -import java.util.concurrent.CompletableFuture; - -record WasmFlagResolverService( - ResolverApi wasmResolveApi, StickyResolveStrategy stickyResolveStrategy) - implements FlagResolverService { - @Override - public CompletableFuture resolveFlags(ResolveFlagsRequest request) { - return wasmResolveApi.resolveWithSticky( - ResolveWithStickyRequest.newBuilder() - .setResolveRequest(request) - .setFailFastOnSticky(getFailFast(stickyResolveStrategy)) - .build()); - } - - private static boolean getFailFast(StickyResolveStrategy stickyResolveStrategy) { - return stickyResolveStrategy instanceof ResolverFallback; - } - - public void close() { - wasmResolveApi.close(); - } -} diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/WasmResolveApi.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/WasmResolveApi.java deleted file mode 100644 index ae72889b..00000000 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/WasmResolveApi.java +++ /dev/null @@ -1,233 +0,0 @@ -package com.spotify.confidence; - -import com.dylibso.chicory.compiler.MachineFactoryCompiler; -import com.dylibso.chicory.runtime.ExportFunction; -import com.dylibso.chicory.runtime.ImportFunction; -import com.dylibso.chicory.runtime.ImportValues; -import com.dylibso.chicory.runtime.Instance; -import com.dylibso.chicory.runtime.Memory; -import com.dylibso.chicory.wasm.Parser; -import com.dylibso.chicory.wasm.WasmModule; -import com.dylibso.chicory.wasm.types.FunctionType; -import com.dylibso.chicory.wasm.types.ValType; -import com.google.protobuf.ByteString; -import com.google.protobuf.GeneratedMessage; -import com.google.protobuf.InvalidProtocolBufferException; -import com.google.protobuf.Timestamp; -import com.spotify.confidence.flags.resolver.v1.LogMessage; -import com.spotify.confidence.flags.resolver.v1.ResolveWithStickyRequest; -import com.spotify.confidence.flags.resolver.v1.ResolveWithStickyResponse; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsResponse; -import com.spotify.confidence.shaded.flags.resolver.v1.WriteFlagLogsRequest; -import com.spotify.confidence.wasm.Messages; -import java.io.IOException; -import java.io.InputStream; -import java.time.Instant; -import java.util.List; -import java.util.concurrent.locks.ReadWriteLock; -import java.util.concurrent.locks.ReentrantReadWriteLock; -import java.util.function.Function; - -class WasmResolveApi { - private final FunctionType HOST_FN_TYPE = - FunctionType.of(List.of(ValType.I32), List.of(ValType.I32)); - private final Instance instance; - private boolean isConsumed = false; - - // interop - private final ExportFunction wasmMsgAlloc; - private final ExportFunction wasmMsgFree; - private final WasmFlagLogger writeFlagLogs; - - // api - private final ExportFunction wasmMsgGuestSetResolverState; - private final ExportFunction wasmMsgFlushLogs; - private final ExportFunction wasmMsgGuestResolve; - private final ExportFunction wasmMsgGuestResolveWithSticky; - private final ReadWriteLock wasmLock = new ReentrantReadWriteLock(); - - public WasmResolveApi(WasmFlagLogger flagLogger) { - this.writeFlagLogs = flagLogger; - try (InputStream wasmStream = - getClass().getClassLoader().getResourceAsStream("wasm/confidence_resolver.wasm")) { - if (wasmStream == null) { - throw new RuntimeException("Could not find confidence_resolver.wasm in resources"); - } - final WasmModule module = Parser.parse(wasmStream); - instance = - Instance.builder(module) - .withImportValues( - ImportValues.builder() - .addFunction( - createImportFunction( - "current_time", Messages.Void::parseFrom, this::currentTime)) - .addFunction( - createImportFunction("log_message", LogMessage::parseFrom, this::log)) - .addFunction( - new ImportFunction( - "wasm_msg", - "wasm_msg_current_thread_id", - FunctionType.of(List.of(), List.of(ValType.I32)), - this::currentThreadId)) - .build()) - .withMachineFactory(MachineFactoryCompiler::compile) - .build(); - wasmMsgAlloc = instance.export("wasm_msg_alloc"); - wasmMsgFree = instance.export("wasm_msg_free"); - wasmMsgGuestSetResolverState = instance.export("wasm_msg_guest_set_resolver_state"); - wasmMsgFlushLogs = instance.export("wasm_msg_guest_flush_logs"); - wasmMsgGuestResolve = instance.export("wasm_msg_guest_resolve"); - wasmMsgGuestResolveWithSticky = instance.export("wasm_msg_guest_resolve_with_sticky"); - } catch (IOException e) { - throw new RuntimeException("Failed to load WASM module", e); - } - } - - private GeneratedMessage log(LogMessage message) { - System.out.println(message.getMessage()); - return Messages.Void.getDefaultInstance(); - } - - private long[] currentThreadId(Instance instance, long... longs) { - return new long[] {0}; - } - - private Timestamp currentTime(Messages.Void unused) { - return Timestamp.newBuilder().setSeconds(Instant.now().getEpochSecond()).build(); - } - - public void setResolverState(byte[] state, String accountId) { - final var resolverStateRequest = - Messages.SetResolverStateRequest.newBuilder() - .setState(ByteString.copyFrom(state)) - .setAccountId(accountId) - .build(); - final byte[] request = - Messages.Request.newBuilder() - .setData(ByteString.copyFrom(resolverStateRequest.toByteArray())) - .build() - .toByteArray(); - final int addr = transfer(request); - final int respPtr = (int) wasmMsgGuestSetResolverState.apply(addr)[0]; - consumeResponse(respPtr, Messages.Void::parseFrom); - } - - public void close() { - wasmLock.readLock().lock(); - try { - final var voidRequest = Messages.Void.getDefaultInstance(); - final var reqPtr = transferRequest(voidRequest); - final var respPtr = (int) wasmMsgFlushLogs.apply(reqPtr)[0]; - final var request = consumeResponse(respPtr, WriteFlagLogsRequest::parseFrom); - writeFlagLogs.write(request); - isConsumed = true; - } finally { - wasmLock.readLock().unlock(); - } - } - - public ResolveWithStickyResponse resolveWithSticky(ResolveWithStickyRequest request) - throws IsClosedException { - if (!wasmLock.writeLock().tryLock() || isConsumed) { - throw new IsClosedException(); - } - try { - final int reqPtr = transferRequest(request); - final int respPtr = (int) wasmMsgGuestResolveWithSticky.apply(reqPtr)[0]; - return consumeResponse(respPtr, ResolveWithStickyResponse::parseFrom); - } finally { - wasmLock.writeLock().unlock(); - } - } - - public ResolveFlagsResponse resolve(ResolveFlagsRequest request) throws IsClosedException { - if (!wasmLock.writeLock().tryLock()) { - throw new IsClosedException(); - } - try { - final int reqPtr = transferRequest(request); - final int respPtr = (int) wasmMsgGuestResolve.apply(reqPtr)[0]; - return consumeResponse(respPtr, ResolveFlagsResponse::parseFrom); - } finally { - wasmLock.writeLock().unlock(); - } - } - - private T consumeResponse(int addr, ParserFn codec) { - try { - final Messages.Response response = Messages.Response.parseFrom(consume(addr)); - if (response.hasError()) { - throw new RuntimeException(response.getError()); - } else { - return codec.apply(response.getData().toByteArray()); - } - } catch (InvalidProtocolBufferException e) { - throw new RuntimeException(e); - } - } - - private T consumeRequest(int addr, ParserFn codec) { - try { - final Messages.Request request = Messages.Request.parseFrom(consume(addr)); - return codec.apply(request.getData().toByteArray()); - } catch (InvalidProtocolBufferException e) { - throw new RuntimeException(e); - } - } - - private int transferRequest(GeneratedMessage message) { - final byte[] request = - Messages.Request.newBuilder().setData(message.toByteString()).build().toByteArray(); - return transfer(request); - } - - private int transferResponseSuccess(GeneratedMessage response) { - final byte[] wrapperBytes = - Messages.Response.newBuilder().setData(response.toByteString()).build().toByteArray(); - return transfer(wrapperBytes); - } - - private int transferResponseError(String error) { - final byte[] wrapperBytes = - Messages.Response.newBuilder().setError(error).build().toByteArray(); - return transfer(wrapperBytes); - } - - private byte[] consume(int addr) { - final Memory mem = instance.memory(); - final int len = (int) (mem.readU32(addr - 4) - 4L); - final byte[] data = mem.readBytes(addr, len); - wasmMsgFree.apply(addr); - return data; - } - - private int transfer(byte[] data) { - final Memory mem = instance.memory(); - final int addr = (int) wasmMsgAlloc.apply(data.length)[0]; - mem.write(addr, data); - return addr; - } - - private ImportFunction createImportFunction( - String name, ParserFn reqCodec, Function impl) { - return new ImportFunction( - "wasm_msg", - "wasm_msg_host_" + name, - HOST_FN_TYPE, - (instance1, args) -> { - try { - final T message = consumeRequest((int) args[0], reqCodec); - final GeneratedMessage response = impl.apply(message); - return new long[] {transferResponseSuccess(response)}; - } catch (Exception e) { - return new long[] {transferResponseError(e.getMessage())}; - } - }); - } - - private interface ParserFn { - - T apply(byte[] data) throws InvalidProtocolBufferException; - } -} diff --git a/openfeature-provider-local/src/test/java/com/spotify/confidence/AssignLoggerTest.java b/openfeature-provider-local/src/test/java/com/spotify/confidence/AssignLoggerTest.java deleted file mode 100644 index faa1504c..00000000 --- a/openfeature-provider-local/src/test/java/com/spotify/confidence/AssignLoggerTest.java +++ /dev/null @@ -1,217 +0,0 @@ -package com.spotify.confidence; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; -import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.argThat; -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.codahale.metrics.MetricRegistry; -import com.spotify.confidence.shaded.flags.resolver.v1.InternalFlagLoggerServiceGrpc; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveTokenV1; -import com.spotify.confidence.shaded.flags.resolver.v1.Sdk; -import com.spotify.confidence.shaded.flags.resolver.v1.WriteFlagAssignedRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.WriteFlagAssignedResponse; -import com.spotify.confidence.shaded.flags.resolver.v1.events.FlagAssigned; -import com.spotify.confidence.shaded.iam.v1.Client; -import com.spotify.confidence.shaded.iam.v1.ClientCredential; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import org.apache.commons.lang3.RandomStringUtils; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.InOrder; -import org.mockito.Mockito; - -public class AssignLoggerTest { - private InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub stub; - private AssignLogger logger; - - @BeforeEach - public void beforeEach() { - stub = mock(InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub.class); - logger = new AssignLogger(stub, null, new MetricRegistry(), 8 * 1024 * 1024); - } - - @Test - public void canSendAssignsThatNeedMultipleRequests() { - when(stub.writeFlagAssigned(any())).thenReturn(WriteFlagAssignedResponse.getDefaultInstance()); - addManyAssigns(); - logger.checkpoint(); - assertThat(logger.queuedAssigns()).isEmpty(); - verify(stub, times(2)).writeFlagAssigned(any()); - } - - @Test - public void ifFirstWriteFailsThenAllMessagesAreKept() { - addManyAssigns(); - final var stateBefore = logger.queuedAssigns(); - assertThat(stateBefore).hasSize(10000); - when(stub.writeFlagAssigned(any())).thenThrow(new RuntimeException("Throw up")); - - assertThatExceptionOfType(RuntimeException.class) - .isThrownBy(() -> logger.checkpoint()) - .withMessage("Throw up"); - - final var stateAfter = logger.queuedAssigns(); - assertThat(stateAfter).containsExactlyInAnyOrderElementsOf(stateBefore); - } - - @Test - public void ifFirstWriteWorksButSecondFailsThenTheNonSentMessagesAreKept() { - addManyAssigns(); - final var stateBefore = logger.queuedAssigns(); - assertThat(stateBefore).hasSize(10000); - final AtomicInteger remaining = new AtomicInteger(10000); - final List sent = new ArrayList<>(); - when(stub.writeFlagAssigned(any())) - .then( - invocation -> { - final WriteFlagAssignedRequest req = - invocation.getArgument(0, WriteFlagAssignedRequest.class); - remaining.getAndUpdate(i -> i - req.getFlagAssignedCount()); - sent.addAll(req.getFlagAssignedList()); - return WriteFlagAssignedResponse.getDefaultInstance(); - }) - .thenThrow(new RuntimeException("Throw up")); - - assertThatExceptionOfType(RuntimeException.class) - .isThrownBy(() -> logger.checkpoint()) - .withMessage("Throw up"); - - final var stateAfter = logger.queuedAssigns(); - assertThat(remaining.get()).isGreaterThan(0); - assertThat(remaining.get()).isLessThan(10000); - - assertThat(stateAfter).hasSize(remaining.get()); - assertThat(stateAfter).doesNotContainAnyElementsOf(sent); - } - - @Test - void reducesCapacityBySizeOfAssigned() { - final String resolveId = RandomStringUtils.randomAlphabetic(100); - final Sdk sdk = Sdk.getDefaultInstance(); - final List flagsToApply = - List.of( - new FlagToApply( - Instant.now(), - ResolveTokenV1.AssignedFlag.newBuilder() - .setAssignmentId(RandomStringUtils.randomAlphabetic(100)) - .setFlag(RandomStringUtils.randomAlphabetic(100)) - .build())); - final AccountClient accountClient = - new AccountClient( - "foobar", Client.getDefaultInstance(), ClientCredential.getDefaultInstance()); - final int size = - FlagLogger.createFlagAssigned(resolveId, sdk, flagsToApply, accountClient) - .getSerializedSize(); - final long capacityBefore = logger.remainingCapacity(); - logger.logAssigns(resolveId, sdk, flagsToApply, accountClient); - assertThat(logger.remainingCapacity()).isEqualTo(capacityBefore - size); - } - - @Test - void increasesCapacityOnSuccessfulSend() { - when(stub.writeFlagAssigned(any())).thenReturn(WriteFlagAssignedResponse.getDefaultInstance()); - final long capacityBefore = logger.remainingCapacity(); - addManyAssigns(); - assertThat(logger.dropCount()).isZero(); - assertThat(logger.remainingCapacity()).isLessThan(capacityBefore); - logger.checkpoint(); - assertThat(logger.remainingCapacity()).isEqualTo(capacityBefore); - } - - @Test - void capacityUnchangedOnFailedSend() { - when(stub.writeFlagAssigned(any())).thenThrow(new RuntimeException("Throw up")); - addManyAssigns(); - final long capacityAfter = logger.remainingCapacity(); - - assertThatExceptionOfType(RuntimeException.class).isThrownBy(logger::checkpoint); - assertThat(logger.remainingCapacity()).isEqualTo(capacityAfter); - } - - @Test - void eventuallyDropsAssigns() { - assertThat(logger.dropCount()).isZero(); - addManyAssigns(); - addManyAssigns(); - assertThat(logger.dropCount()).isZero(); - addManyAssigns(); - assertThat(logger.dropCount()).isGreaterThan(0); - } - - @Test - void sendsAndResetsDropCount() { - when(stub.writeFlagAssigned(any())).thenReturn(WriteFlagAssignedResponse.getDefaultInstance()); - addManyAssigns(); - addManyAssigns(); - addManyAssigns(); - - final long dropped = logger.dropCount(); - assertThat(dropped).isPositive(); - - logger.checkpoint(); - - assertThat(logger.dropCount()).isZero(); - - final InOrder inOrder = Mockito.inOrder(stub); - inOrder.verify(stub).writeFlagAssigned(matchDropCount(dropped)); - inOrder.verify(stub, atLeastOnce()).writeFlagAssigned(matchDropCount(0)); - inOrder.verifyNoMoreInteractions(); - } - - @Test - void resendsDropCountOnFailure() { - when(stub.writeFlagAssigned(any())) - .thenThrow(RuntimeException.class) - .thenReturn(WriteFlagAssignedResponse.getDefaultInstance()); - - addManyAssigns(); - addManyAssigns(); - addManyAssigns(); - - final long dropped = logger.dropCount(); - assertThat(dropped).isPositive(); - - assertThatExceptionOfType(RuntimeException.class).isThrownBy(logger::checkpoint); - - assertThat(logger.dropCount()).isEqualTo(dropped); - verify(stub).writeFlagAssigned(matchDropCount(dropped)); - - logger.checkpoint(); - - final InOrder inOrder = Mockito.inOrder(stub); - inOrder.verify(stub, times(2)).writeFlagAssigned(matchDropCount(dropped)); - inOrder.verify(stub, atLeastOnce()).writeFlagAssigned(matchDropCount(0)); - inOrder.verifyNoMoreInteractions(); - } - - private static WriteFlagAssignedRequest matchDropCount(long value) { - return argThat(request -> request.getTelemetryData().getDroppedEvents() == value); - } - - private void addManyAssigns() { - // this should be large enough so we have to split in 2 requests - for (int i = 0; i < 10000; i++) { - logger.logAssigns( - RandomStringUtils.randomAlphabetic(100), - Sdk.getDefaultInstance(), - List.of( - new FlagToApply( - Instant.now(), - ResolveTokenV1.AssignedFlag.newBuilder() - .setAssignmentId(RandomStringUtils.randomAlphabetic(100)) - .setFlag(RandomStringUtils.randomAlphabetic(100)) - .build())), - new AccountClient( - "foobar", Client.getDefaultInstance(), ClientCredential.getDefaultInstance())); - } - } -} diff --git a/openfeature-provider-local/src/test/java/com/spotify/confidence/GrpcWasmFlagLoggerTest.java b/openfeature-provider-local/src/test/java/com/spotify/confidence/GrpcWasmFlagLoggerTest.java deleted file mode 100644 index e1eb9fab..00000000 --- a/openfeature-provider-local/src/test/java/com/spotify/confidence/GrpcWasmFlagLoggerTest.java +++ /dev/null @@ -1,235 +0,0 @@ -package com.spotify.confidence; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.spotify.confidence.shaded.flags.admin.v1.ClientResolveInfo; -import com.spotify.confidence.shaded.flags.admin.v1.FlagResolveInfo; -import com.spotify.confidence.shaded.flags.resolver.v1.InternalFlagLoggerServiceGrpc; -import com.spotify.confidence.shaded.flags.resolver.v1.TelemetryData; -import com.spotify.confidence.shaded.flags.resolver.v1.WriteFlagLogsRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.WriteFlagLogsResponse; -import com.spotify.confidence.shaded.flags.resolver.v1.events.ClientInfo; -import com.spotify.confidence.shaded.flags.resolver.v1.events.FlagAssigned; -import java.util.ArrayList; -import java.util.List; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; - -class GrpcWasmFlagLoggerTest { - - @Test - void testEmptyRequest_shouldSkip() { - // Given - final var mockStub = - mock(InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub.class); - final var logger = createLoggerWithMockStub(mockStub); - final var emptyRequest = WriteFlagLogsRequest.newBuilder().build(); - - // When - logger.write(emptyRequest); - - // Then - verify(mockStub, never()).writeFlagLogs(any()); - logger.shutdown(); - } - - @Test - void testSmallRequest_shouldSendAsIs() { - // Given - final var mockStub = - mock(InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub.class); - when(mockStub.writeFlagLogs(any())).thenReturn(WriteFlagLogsResponse.getDefaultInstance()); - final var logger = createLoggerWithMockStub(mockStub); - - final var request = - WriteFlagLogsRequest.newBuilder() - .addAllFlagAssigned(createFlagAssignedList(100)) - .setTelemetryData(TelemetryData.newBuilder().setDroppedEvents(5).build()) - .addClientResolveInfo( - ClientResolveInfo.newBuilder().setClient("clients/test-client").build()) - .addFlagResolveInfo(FlagResolveInfo.newBuilder().setFlag("flags/test-flag").build()) - .build(); - - final ArgumentCaptor captor = - ArgumentCaptor.forClass(WriteFlagLogsRequest.class); - - // When - logger.write(request); - - // Then - verify(mockStub, times(1)).writeFlagLogs(captor.capture()); - - final WriteFlagLogsRequest sentRequest = captor.getValue(); - assertEquals(100, sentRequest.getFlagAssignedCount()); - assertEquals(5, sentRequest.getTelemetryData().getDroppedEvents()); - assertEquals(1, sentRequest.getClientResolveInfoCount()); - assertEquals(1, sentRequest.getFlagResolveInfoCount()); - - logger.shutdown(); - } - - @Test - void testLargeRequest_shouldChunkWithMetadataInFirstChunkOnly() { - // Given - final var mockStub = - mock(InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub.class); - when(mockStub.writeFlagLogs(any())).thenReturn(WriteFlagLogsResponse.getDefaultInstance()); - final var logger = createLoggerWithMockStub(mockStub); - - final int totalFlags = 2500; // Will create 3 chunks: 1000, 1000, 500 - final var request = - WriteFlagLogsRequest.newBuilder() - .addAllFlagAssigned(createFlagAssignedList(totalFlags)) - .setTelemetryData(TelemetryData.newBuilder().setDroppedEvents(10).build()) - .addClientResolveInfo( - ClientResolveInfo.newBuilder().setClient("clients/test-client").build()) - .addFlagResolveInfo(FlagResolveInfo.newBuilder().setFlag("flags/test-flag").build()) - .build(); - - final ArgumentCaptor captor = - ArgumentCaptor.forClass(WriteFlagLogsRequest.class); - - // When - logger.write(request); - - // Then - verify(mockStub, times(3)).writeFlagLogs(captor.capture()); - - final List sentRequests = captor.getAllValues(); - assertEquals(3, sentRequests.size()); - - // First chunk: 1000 flag_assigned + metadata - final WriteFlagLogsRequest firstChunk = sentRequests.get(0); - assertEquals(1000, firstChunk.getFlagAssignedCount()); - assertTrue(firstChunk.hasTelemetryData()); - assertEquals(10, firstChunk.getTelemetryData().getDroppedEvents()); - assertEquals(1, firstChunk.getClientResolveInfoCount()); - assertEquals("clients/test-client", firstChunk.getClientResolveInfo(0).getClient()); - assertEquals(1, firstChunk.getFlagResolveInfoCount()); - assertEquals("flags/test-flag", firstChunk.getFlagResolveInfo(0).getFlag()); - - // Second chunk: 1000 flag_assigned only, no metadata - final WriteFlagLogsRequest secondChunk = sentRequests.get(1); - assertEquals(1000, secondChunk.getFlagAssignedCount()); - assertEquals(false, secondChunk.hasTelemetryData()); - assertEquals(0, secondChunk.getClientResolveInfoCount()); - assertEquals(0, secondChunk.getFlagResolveInfoCount()); - - // Third chunk: 500 flag_assigned only, no metadata - final WriteFlagLogsRequest thirdChunk = sentRequests.get(2); - assertEquals(500, thirdChunk.getFlagAssignedCount()); - assertEquals(false, thirdChunk.hasTelemetryData()); - assertEquals(0, thirdChunk.getClientResolveInfoCount()); - assertEquals(0, thirdChunk.getFlagResolveInfoCount()); - - logger.shutdown(); - } - - @Test - void testExactlyAtChunkBoundary_shouldCreateTwoChunks() { - // Given - final var mockStub = - mock(InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub.class); - when(mockStub.writeFlagLogs(any())).thenReturn(WriteFlagLogsResponse.getDefaultInstance()); - final var logger = createLoggerWithMockStub(mockStub); - - final int totalFlags = 2000; // Exactly 2 chunks of 1000 - final var request = - WriteFlagLogsRequest.newBuilder() - .addAllFlagAssigned(createFlagAssignedList(totalFlags)) - .setTelemetryData(TelemetryData.newBuilder().setDroppedEvents(7).build()) - .build(); - - final ArgumentCaptor captor = - ArgumentCaptor.forClass(WriteFlagLogsRequest.class); - - // When - logger.write(request); - - // Then - verify(mockStub, times(2)).writeFlagLogs(captor.capture()); - - final List sentRequests = captor.getAllValues(); - assertEquals(2, sentRequests.size()); - - // First chunk with metadata - assertEquals(1000, sentRequests.get(0).getFlagAssignedCount()); - assertTrue(sentRequests.get(0).hasTelemetryData()); - - // Second chunk without metadata - assertEquals(1000, sentRequests.get(1).getFlagAssignedCount()); - assertEquals(false, sentRequests.get(1).hasTelemetryData()); - - logger.shutdown(); - } - - @Test - void testOnlyMetadata_noFlagAssigned_shouldSendAsIs() { - // Given - final var mockStub = - mock(InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub.class); - when(mockStub.writeFlagLogs(any())).thenReturn(WriteFlagLogsResponse.getDefaultInstance()); - final var logger = createLoggerWithMockStub(mockStub); - - final var request = - WriteFlagLogsRequest.newBuilder() - .setTelemetryData(TelemetryData.newBuilder().setDroppedEvents(3).build()) - .addClientResolveInfo( - ClientResolveInfo.newBuilder().setClient("clients/test-client").build()) - .build(); - - final ArgumentCaptor captor = - ArgumentCaptor.forClass(WriteFlagLogsRequest.class); - - // When - logger.write(request); - - // Then - verify(mockStub, times(1)).writeFlagLogs(captor.capture()); - - final WriteFlagLogsRequest sentRequest = captor.getValue(); - assertEquals(0, sentRequest.getFlagAssignedCount()); - assertTrue(sentRequest.hasTelemetryData()); - assertEquals(1, sentRequest.getClientResolveInfoCount()); - - logger.shutdown(); - } - - // Helper methods - - private List createFlagAssignedList(int count) { - final List list = new ArrayList<>(); - for (int i = 0; i < count; i++) { - list.add( - FlagAssigned.newBuilder() - .setResolveId("resolve-" + i) - .setClientInfo( - ClientInfo.newBuilder() - .setClient("clients/test-client") - .setClientCredential("clients/test-client/credentials/cred-1") - .build()) - .addFlags( - FlagAssigned.AppliedFlag.newBuilder() - .setFlag("flags/test-flag-" + i) - .setTargetingKey("user-" + i) - .setAssignmentId("assignment-" + i) - .build()) - .build()); - } - return list; - } - - private GrpcWasmFlagLogger createLoggerWithMockStub( - InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub mockStub) { - // Create logger with synchronous test writer - return new GrpcWasmFlagLogger( - new ApiSecret("test-client-id", "test-client-secret"), mockStub::writeFlagLogs); - } -} diff --git a/openfeature-provider-local/src/test/java/com/spotify/confidence/InMemoryMaterializationRepoExample.java b/openfeature-provider-local/src/test/java/com/spotify/confidence/InMemoryMaterializationRepoExample.java deleted file mode 100644 index f921d5b6..00000000 --- a/openfeature-provider-local/src/test/java/com/spotify/confidence/InMemoryMaterializationRepoExample.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.spotify.confidence; - -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class InMemoryMaterializationRepoExample implements MaterializationRepository { - - private static final Logger logger = - LoggerFactory.getLogger(InMemoryMaterializationRepoExample.class); - private final Map> storage = new ConcurrentHashMap<>(); - - /** - * Helper method to create a map with a default, empty MaterializationInfo. - * - * @param key The key to use in the returned map. - * @return A map containing the key and a default MaterializationInfo object. - */ - private static Map createEmptyMap(String key) { - final MaterializationInfo emptyInfo = new MaterializationInfo(false, new HashMap<>()); - final Map map = new HashMap<>(); - map.put(key, emptyInfo); - return map; - } - - @Override - public CompletableFuture> loadMaterializedAssignmentsForUnit( - String unit, String materialization) { - final Map unitAssignments = storage.get(unit); - if (unitAssignments != null) { - if (unitAssignments.containsKey(materialization)) { - final Map result = new HashMap<>(); - result.put(materialization, unitAssignments.get(materialization)); - logger.debug("Cache hit for unit: {}, materialization: {}", unit, materialization); - return CompletableFuture.supplyAsync(() -> result); - } else { - logger.debug( - "Materialization {} not found in cached data for unit: {}", materialization, unit); - return CompletableFuture.completedFuture(createEmptyMap(materialization)); - } - } - - // If unitAssignments was null (cache miss for the unit), return an empty map structure. - return CompletableFuture.completedFuture(createEmptyMap(materialization)); - } - - @Override - public CompletableFuture storeAssignment( - String unit, Map assignments) { - if (unit == null) { - return CompletableFuture.completedFuture(null); - } - - // Use 'compute' for an atomic update operation on the ConcurrentHashMap. - storage.compute( - unit, - (k, existingEntry) -> { - if (existingEntry == null) { - // If no entry exists, create a new one. - // We create a new HashMap to avoid storing a reference to the potentially mutable - // 'assignments' map. - return assignments == null ? new HashMap<>() : new HashMap<>(assignments); - } else { - // If an entry exists, merge the new assignments into it. - // This is equivalent to Kotlin's 'existingEntry.plus(assignments ?: emptyMap())'. - final Map newEntry = new HashMap<>(existingEntry); - if (assignments != null) { - newEntry.putAll(assignments); - } - return newEntry; - } - }); - - final int assignmentCount = (assignments != null) ? assignments.size() : 0; - logger.debug("Stored {} assignments for unit: {}", assignmentCount, unit); - - return CompletableFuture.completedFuture(null); - } - - @Override - public void close() { - storage.clear(); - logger.debug("In-memory storage cleared."); - } -} diff --git a/openfeature-provider-local/src/test/java/com/spotify/confidence/ResolveTest.java b/openfeature-provider-local/src/test/java/com/spotify/confidence/ResolveTest.java deleted file mode 100644 index ce4a8de2..00000000 --- a/openfeature-provider-local/src/test/java/com/spotify/confidence/ResolveTest.java +++ /dev/null @@ -1,300 +0,0 @@ -package com.spotify.confidence; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; -import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; - -import com.google.protobuf.Struct; -import com.google.protobuf.util.Structs; -import com.google.protobuf.util.Values; -import com.spotify.confidence.shaded.flags.admin.v1.Flag; -import com.spotify.confidence.shaded.flags.admin.v1.Segment; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveReason; -import com.spotify.confidence.shaded.flags.types.v1.FlagSchema; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -abstract class ResolveTest extends TestBase { - private static final String flag1 = "flags/flag-1"; - - private static final String flagOff = flag1 + "/variants/offf"; - private static final String flagOn = flag1 + "/variants/onnn"; - - private static final Flag.Variant variantOff = - Flag.Variant.newBuilder() - .setName(flagOff) - .setValue(Structs.of("data", Values.of("off"))) - .build(); - private static final Flag.Variant variantOn = - Flag.Variant.newBuilder() - .setName(flagOn) - .setValue(Structs.of("data", Values.of("on"))) - .build(); - - private static final FlagSchema.StructFlagSchema schema1 = - FlagSchema.StructFlagSchema.newBuilder() - .putSchema( - "data", - FlagSchema.newBuilder() - .setStringSchema(FlagSchema.StringFlagSchema.newBuilder().build()) - .build()) - .putSchema( - "extra", - FlagSchema.newBuilder() - .setStringSchema(FlagSchema.StringFlagSchema.newBuilder().build()) - .build()) - .build(); - private static final String segmentA = "segments/seg-a"; - static final byte[] exampleStateBytes; - static final byte[] exampleStateWithMaterializationBytes; - private static final Map flags = - Map.of( - flag1, - Flag.newBuilder() - .setName(flag1) - .setState(Flag.State.ACTIVE) - .setSchema(schema1) - .addVariants(variantOff) - .addVariants(variantOn) - .addClients(clientName) - .addRules( - Flag.Rule.newBuilder() - .setName("MyRule") - .setSegment(segmentA) - .setEnabled(true) - .setAssignmentSpec( - Flag.Rule.AssignmentSpec.newBuilder() - .setBucketCount(2) - .addAssignments( - Flag.Rule.Assignment.newBuilder() - .setAssignmentId(flagOff) - .setVariant( - Flag.Rule.Assignment.VariantAssignment.newBuilder() - .setVariant(flagOff) - .build()) - .addBucketRanges( - Flag.Rule.BucketRange.newBuilder() - .setLower(0) - .setUpper(1) - .build()) - .build()) - .addAssignments( - Flag.Rule.Assignment.newBuilder() - .setAssignmentId(flagOn) - .setVariant( - Flag.Rule.Assignment.VariantAssignment.newBuilder() - .setVariant(flagOn) - .build()) - .addBucketRanges( - Flag.Rule.BucketRange.newBuilder() - .setLower(1) - .setUpper(2) - .build()) - .build()) - .build()) - .build()) - .build()); - - private static final Map flagsWithMaterialization = - Map.of( - flag1, - Flag.newBuilder() - .setName(flag1) - .setState(Flag.State.ACTIVE) - .setSchema(schema1) - .addVariants(variantOff) - .addVariants(variantOn) - .addClients(clientName) - .addRules( - Flag.Rule.newBuilder() - .setName("MyRule") - .setSegment(segmentA) - .setEnabled(true) - .setMaterializationSpec( - Flag.Rule.MaterializationSpec.newBuilder() - .setReadMaterialization("read-mat") - .setMode( - Flag.Rule.MaterializationSpec.MaterializationReadMode.newBuilder() - .setMaterializationMustMatch(true) - .setSegmentTargetingCanBeIgnored(false) - .build()) - .setWriteMaterialization("write-mat") - .build()) - .setAssignmentSpec( - Flag.Rule.AssignmentSpec.newBuilder() - .setBucketCount(2) - .addAssignments( - Flag.Rule.Assignment.newBuilder() - .setAssignmentId(flagOff) - .setVariant( - Flag.Rule.Assignment.VariantAssignment.newBuilder() - .setVariant(flagOff) - .build()) - .addBucketRanges( - Flag.Rule.BucketRange.newBuilder() - .setLower(0) - .setUpper(1) - .build()) - .build()) - .addAssignments( - Flag.Rule.Assignment.newBuilder() - .setAssignmentId(flagOn) - .setVariant( - Flag.Rule.Assignment.VariantAssignment.newBuilder() - .setVariant(flagOn) - .build()) - .addBucketRanges( - Flag.Rule.BucketRange.newBuilder() - .setLower(1) - .setUpper(2) - .build()) - .build()) - .build()) - .build()) - .build()); - protected static final Map segments = - Map.of(segmentA, Segment.newBuilder().setName(segmentA).build()); - - static { - exampleStateBytes = buildResolverStateBytes(flags); - exampleStateWithMaterializationBytes = buildResolverStateBytes(flagsWithMaterialization); - } - - protected ResolveTest(boolean isWasm) { - super(exampleStateBytes); - } - - @BeforeAll - public static void beforeAll() { - TestBase.setup(); - } - - @Test - public void testInvalidSecret() { - assertThatExceptionOfType(RuntimeException.class) - .isThrownBy( - () -> - resolveWithContext( - List.of("flags/asd"), - "foo", - Struct.newBuilder().build(), - true, - "invalid-secret")) - .withMessage("client secret not found"); - } - - @Test - public void testInvalidFlag() { - final var response = - resolveWithContext(List.of("flags/asd"), "foo", Struct.newBuilder().build(), false); - assertThat(response.getResolvedFlagsList()).isEmpty(); - assertThat(response.getResolveId()).isNotEmpty(); - } - - @Test - public void testResolveFlag() { - final var response = - resolveWithContext(List.of(flag1), "foo", Struct.newBuilder().build(), true); - assertThat(response.getResolveId()).isNotEmpty(); - final Struct expectedValue = variantOn.getValue(); - - assertEquals(variantOn.getName(), response.getResolvedFlags(0).getVariant()); - assertEquals(expectedValue, response.getResolvedFlags(0).getValue()); - assertEquals(schema1, response.getResolvedFlags(0).getFlagSchema()); - } - - @Test - public void testResolveFlagWithEncryptedResolveToken() { - final var response = - resolveWithContext(List.of(flag1), "foo", Struct.newBuilder().build(), false); - assertThat(response.getResolveId()).isNotEmpty(); - final Struct expectedValue = variantOn.getValue(); - - assertEquals(variantOn.getName(), response.getResolvedFlags(0).getVariant()); - assertEquals(expectedValue, response.getResolvedFlags(0).getValue()); - assertEquals(schema1, response.getResolvedFlags(0).getFlagSchema()); - assertThat(response.getResolveToken()).isNotEmpty(); - } - - // @Test - // public void perf() { - // final ScheduledExecutorService flagsFetcherExecutor = - // Executors.newScheduledThreadPool(1, new - // ThreadFactoryBuilder().setDaemon(true).build()); - // - // flagsFetcherExecutor.scheduleAtFixedRate( - // () -> { - // System.out.println("flagsFetcherExecutor started"); - // resolverServiceFactory.setState(desiredState.toProto().toByteArray()); - // System.out.println("flagsFetcherExecutor ended"); - // }, - // 2, - // 2, - // TimeUnit.SECONDS); - // - // for (int i = 1; i <= 100; i++) { - // final var start = System.currentTimeMillis(); - // for (int j = 0; j < 10000; j++) { - // resolveWithContext(List.of(flag1), "foo", "bar", Struct.newBuilder().build(), true); - // } - // System.out.println( - // "Iteration " + i + " took " + (System.currentTimeMillis() - start) + " ms"); - // } - // } - - @Test - public void testTooLongKey() { - assertThatExceptionOfType(RuntimeException.class) - .isThrownBy( - () -> - resolveWithContext( - List.of(flag1), "a".repeat(101), Struct.newBuilder().build(), false)) - .withMessageContaining("Targeting key is too larger, max 100 characters."); - } - - @Test - public void testResolveIntegerTargetingKeyTyped() { - final var response = - resolveWithNumericTargetingKey(List.of(flag1), 1234567890, Struct.newBuilder().build()); - - assertThat(response.getResolvedFlagsList()).hasSize(1); - assertEquals(ResolveReason.RESOLVE_REASON_MATCH, response.getResolvedFlags(0).getReason()); - } - - @Test - public void testResolveDecimalUsername() { - final var response = - resolveWithNumericTargetingKey(List.of(flag1), 3.14159d, Struct.newBuilder().build()); - - assertThat(response.getResolvedFlagsList()).hasSize(1); - assertEquals( - ResolveReason.RESOLVE_REASON_TARGETING_KEY_ERROR, response.getResolvedFlags(0).getReason()); - } - - private static byte[] buildResolverStateBytes(Map flagsMap) { - final var builder = com.spotify.confidence.shaded.flags.admin.v1.ResolverState.newBuilder(); - builder.addAllFlags(flagsMap.values()); - builder.addAllSegmentsNoBitsets(segments.values()); - // All-one bitset for each segment - segments - .keySet() - .forEach( - name -> - builder.addBitsets( - com.spotify.confidence.shaded.flags.admin.v1.ResolverState.PackedBitset - .newBuilder() - .setSegment(name) - .setFullBitset(true) - .build())); - builder.addClients( - com.spotify.confidence.shaded.iam.v1.Client.newBuilder().setName(clientName).build()); - builder.addClientCredentials( - com.spotify.confidence.shaded.iam.v1.ClientCredential.newBuilder() - .setName(credentialName) - .setClientSecret(TestBase.secret) - .build()); - return builder.build().toByteArray(); - } -} diff --git a/openfeature-provider-local/src/test/java/com/spotify/confidence/SwapWasmResolverApiTest.java b/openfeature-provider-local/src/test/java/com/spotify/confidence/SwapWasmResolverApiTest.java deleted file mode 100644 index 3328b883..00000000 --- a/openfeature-provider-local/src/test/java/com/spotify/confidence/SwapWasmResolverApiTest.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.spotify.confidence; - -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.dylibso.chicory.wasm.ChicoryException; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsResponse; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.Test; - -public class SwapWasmResolverApiTest { - - @Test - public void testChicoryExceptionTriggersStateReset() throws Exception { - // Create a mock WasmFlagLogger - final WasmFlagLogger mockLogger = mock(WasmFlagLogger.class); - - // Use valid test state bytes and accountId - final byte[] initialState = ResolveTest.exampleStateBytes; - final String accountId = "test-account-id"; - - // Create a spy of SwapWasmResolverApi to verify method calls - final SwapWasmResolverApi swapApi = - spy( - new SwapWasmResolverApi( - mockLogger, initialState, accountId, mock(ResolverFallback.class))); - - // Create a mock WasmResolveApi that throws ChicoryException - final WasmResolveApi mockWasmApi = mock(WasmResolveApi.class); - when(mockWasmApi.resolve(any(ResolveFlagsRequest.class))) - .thenThrow(new ChicoryException("WASM runtime error")); - - // Replace the internal WasmResolveApi with our mock - final var field = SwapWasmResolverApi.class.getDeclaredField("wasmResolverApiRef"); - field.setAccessible(true); - @SuppressWarnings("unchecked") - final AtomicReference ref = - (AtomicReference) field.get(swapApi); - ref.set(mockWasmApi); - - // Create a test resolve request - final ResolveFlagsRequest request = - ResolveFlagsRequest.newBuilder() - .addFlags("test-flag") - .setClientSecret("test-secret") - .build(); - - // Execute the resolve and expect ChicoryException to be thrown - assertThrows(ChicoryException.class, () -> swapApi.resolve(request)); - - // Verify that updateStateAndFlushLogs was called with null state and the accountId - verify(swapApi, atLeastOnce()).updateStateAndFlushLogs(null, accountId); - } - - @Test - public void testIsClosedExceptionTriggersRetry() throws Exception { - // Create a mock WasmFlagLogger - final WasmFlagLogger mockLogger = mock(WasmFlagLogger.class); - - // Use valid test state bytes and accountId - final byte[] initialState = ResolveTest.exampleStateBytes; - final String accountId = "test-account-id"; - - // Create a spy of SwapWasmResolverApi to verify method calls - final SwapWasmResolverApi swapApi = - spy( - new SwapWasmResolverApi( - mockLogger, initialState, accountId, mock(ResolverFallback.class))); - - // Create a mock WasmResolveApi that throws IsClosedException on first call, then succeeds - final WasmResolveApi mockWasmApi = mock(WasmResolveApi.class); - final ResolveFlagsResponse mockResponse = ResolveFlagsResponse.newBuilder().build(); - when(mockWasmApi.resolve(any(ResolveFlagsRequest.class))) - .thenThrow(new IsClosedException()) - .thenReturn(mockResponse); - - // Replace the internal WasmResolveApi with our mock - final var field = SwapWasmResolverApi.class.getDeclaredField("wasmResolverApiRef"); - field.setAccessible(true); - @SuppressWarnings("unchecked") - final AtomicReference ref = - (AtomicReference) field.get(swapApi); - ref.set(mockWasmApi); - - // Create a test resolve request - final ResolveFlagsRequest request = - ResolveFlagsRequest.newBuilder() - .addFlags("flags/flag-1") - .setClientSecret(TestBase.secret.getSecret()) - .build(); - - // Call resolve - it should retry when IsClosedException is thrown and succeed on second attempt - final ResolveFlagsResponse response = swapApi.resolve(request); - - // Verify response is not null - assertNotNull(response); - - // Verify that the mock WasmResolveApi.resolve was called twice (first threw exception, second - // succeeded) - verify(mockWasmApi, times(2)).resolve(request); - } -} diff --git a/openfeature-provider-local/src/test/java/com/spotify/confidence/TestBase.java b/openfeature-provider-local/src/test/java/com/spotify/confidence/TestBase.java deleted file mode 100644 index 66f7a093..00000000 --- a/openfeature-provider-local/src/test/java/com/spotify/confidence/TestBase.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.spotify.confidence; - -import static org.mockito.Mockito.mock; - -import com.google.protobuf.Struct; -import com.google.protobuf.util.Structs; -import com.google.protobuf.util.Values; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsResponse; -import com.spotify.confidence.shaded.flags.resolver.v1.WriteFlagLogsRequest; -import com.spotify.confidence.shaded.iam.v1.Client; -import com.spotify.confidence.shaded.iam.v1.ClientCredential; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ExecutionException; -import org.junit.jupiter.api.BeforeEach; - -public class TestBase { - protected final ResolverFallback mockFallback = mock(ResolverFallback.class); - protected static final ClientCredential.ClientSecret secret = - ClientCredential.ClientSecret.newBuilder().setSecret("very-secret").build(); - protected final byte[] desiredStateBytes; - protected static LocalResolverServiceFactory resolverServiceFactory; - static final String account = "accounts/foo"; - static final String clientName = "clients/client"; - static final String credentialName = clientName + "/credentials/creddy"; - protected static final Map secrets = - Map.of( - secret, - new AccountClient( - account, - Client.newBuilder().setName(clientName).build(), - ClientCredential.newBuilder() - .setName(credentialName) - .setClientSecret(secret) - .build())); - - protected TestBase(byte[] stateBytes) { - this.desiredStateBytes = stateBytes; - final var wasmResolverApi = - new SwapWasmResolverApi( - new WasmFlagLogger() { - @Override - public void write(WriteFlagLogsRequest request) {} - - @Override - public void shutdown() {} - }, - desiredStateBytes, - "", - mockFallback); - resolverServiceFactory = new LocalResolverServiceFactory(wasmResolverApi, mockFallback); - } - - protected static void setup() {} - - @BeforeEach - protected void setUp() {} - - protected ResolveFlagsResponse resolveWithContext( - List flags, String username, Struct struct, boolean apply, String secret) { - try { - return resolverServiceFactory - .create(secret) - .resolveFlags( - ResolveFlagsRequest.newBuilder() - .addAllFlags(flags) - .setClientSecret(secret) - .setEvaluationContext( - Structs.of("targeting_key", Values.of(username), "bar", Values.of(struct))) - .setApply(apply) - .build()) - .get(); - } catch (InterruptedException | ExecutionException e) { - throw new RuntimeException(e); - } - } - - protected ResolveFlagsResponse resolveWithNumericTargetingKey( - List flags, Number targetingKey, Struct struct) { - try { - final var builder = - ResolveFlagsRequest.newBuilder() - .addAllFlags(flags) - .setClientSecret(secret.getSecret()) - .setApply(true); - - if (targetingKey instanceof Double || targetingKey instanceof Float) { - builder.setEvaluationContext( - Structs.of( - "targeting_key", Values.of(targetingKey.doubleValue()), "bar", Values.of(struct))); - } else { - builder.setEvaluationContext( - Structs.of( - "targeting_key", Values.of(targetingKey.longValue()), "bar", Values.of(struct))); - } - - return resolverServiceFactory.create(secret.getSecret()).resolveFlags(builder.build()).get(); - } catch (InterruptedException | java.util.concurrent.ExecutionException e) { - throw new RuntimeException(e); - } - } - - protected ResolveFlagsResponse resolveWithContext( - List flags, String username, Struct struct, boolean apply) { - return resolveWithContext(flags, username, struct, apply, secret.getSecret()); - } -} diff --git a/openfeature-provider-local/src/test/java/com/spotify/confidence/WasmResolveTest.java b/openfeature-provider-local/src/test/java/com/spotify/confidence/WasmResolveTest.java deleted file mode 100644 index 4783caca..00000000 --- a/openfeature-provider-local/src/test/java/com/spotify/confidence/WasmResolveTest.java +++ /dev/null @@ -1,258 +0,0 @@ -package com.spotify.confidence; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.google.protobuf.util.Structs; -import com.google.protobuf.util.Values; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsRequest; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsResponse; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolveReason; -import com.spotify.confidence.shaded.flags.resolver.v1.ResolvedFlag; -import com.spotify.confidence.shaded.flags.types.v1.FlagSchema; -import dev.openfeature.sdk.ImmutableContext; -import dev.openfeature.sdk.ProviderEvaluation; -import dev.openfeature.sdk.Value; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.Test; - -public class WasmResolveTest extends ResolveTest { - // Use materialization-enabled state bytes for StickyResolveStrategy tests - protected final byte[] desiredStateBytes = ResolveTest.exampleStateWithMaterializationBytes; - - public WasmResolveTest() { - super(true); - } - - @Test - public void testAccountStateProviderInterface() { - final AccountStateProvider customProvider = () -> ResolveTest.exampleStateBytes; - final OpenFeatureLocalResolveProvider localResolveProvider = - new OpenFeatureLocalResolveProvider( - customProvider, - "", - TestBase.secret.getSecret(), - new ResolverFallback() { - @Override - public CompletableFuture resolve(ResolveFlagsRequest request) { - return CompletableFuture.completedFuture(null); - } - - @Override - public void close() {} - }); - final ProviderEvaluation objectEvaluation = - localResolveProvider.getObjectEvaluation( - "flag-1", new Value("error"), new ImmutableContext("user1")); - - assertEquals("flags/flag-1/variants/onnn", objectEvaluation.getVariant()); - assertEquals(ResolveReason.RESOLVE_REASON_MATCH.toString(), objectEvaluation.getReason()); - assertNull(objectEvaluation.getErrorCode()); - assertNull(objectEvaluation.getErrorMessage()); - assertTrue(objectEvaluation.getValue().isStructure()); - final var structure = objectEvaluation.getValue().asStructure(); - assertEquals("on", structure.getValue("data").asString()); - } - - @Test - public void testResolverFallbackWhenMaterializationsMissing() { - // Create a mock ResolverFallback - // Create expected response from fallback - final ResolveFlagsResponse expectedFallbackResponse = - ResolveFlagsResponse.newBuilder() - .addResolvedFlags( - ResolvedFlag.newBuilder() - .setFlag("flags/flag-1") - .setFlagSchema( - FlagSchema.StructFlagSchema.newBuilder() - .putSchema( - "data", - FlagSchema.newBuilder() - .setStringSchema( - FlagSchema.StringFlagSchema.newBuilder().build()) - .build()) - .build()) - .setVariant("flags/flag-1/variants/onnn") - .setValue(Structs.of("data", Values.of("on"))) - .setReason(ResolveReason.RESOLVE_REASON_MATCH) - .build()) - .setResolveId("fallback-resolve-id") - .build(); - - // Mock the fallback to return the expected response - when(mockFallback.resolve(any(ResolveFlagsRequest.class))) - .thenReturn(CompletableFuture.completedFuture(expectedFallbackResponse)); - - // Create the provider with mocked fallback strategy - final OpenFeatureLocalResolveProvider provider = - new OpenFeatureLocalResolveProvider( - () -> desiredStateBytes, "", secret.getSecret(), mockFallback); - - // Make the resolve request using OpenFeature API - final ProviderEvaluation evaluation = - provider.getObjectEvaluation( - "flag-1", new Value("default"), new ImmutableContext("test-user")); - - // Assert that we get the expected result - assertEquals("flags/flag-1/variants/onnn", evaluation.getVariant()); - assertEquals(ResolveReason.RESOLVE_REASON_MATCH.toString(), evaluation.getReason()); - assertTrue(evaluation.getValue().isStructure()); - final var structure = evaluation.getValue().asStructure(); - assertEquals("on", structure.getValue("data").asString()); - } - - @Test - public void testMaterializationRepositoryWhenMaterializationsMissing() { - // Create a mock MaterializationRepository - final MaterializationRepository mockRepository = mock(MaterializationRepository.class); - - // Create materialization info that the repository should return - final MaterializationInfo materializationInfo = - new MaterializationInfo(true, Map.of("MyRule", "flags/flag-1/variants/onnn")); - - final Map loadedAssignments = - Map.of("read-mat", materializationInfo); - - // Mock the repository to return materialization info - when(mockRepository.loadMaterializedAssignmentsForUnit(any(String.class), any())) - .thenReturn(CompletableFuture.completedFuture(loadedAssignments)); - - // Create the provider with mocked repository strategy - final OpenFeatureLocalResolveProvider provider = - new OpenFeatureLocalResolveProvider( - () -> desiredStateBytes, "", secret.getSecret(), mockRepository); - - // Make the resolve request using OpenFeature API - final ProviderEvaluation evaluation = - provider.getObjectEvaluation( - "flag-1", new Value("default"), new ImmutableContext("test-user")); - - // Assert that we get the expected result after materialization loading - assertEquals("flags/flag-1/variants/onnn", evaluation.getVariant()); - assertEquals(ResolveReason.RESOLVE_REASON_MATCH.toString(), evaluation.getReason()); - assertTrue(evaluation.getValue().isStructure()); - final var structure = evaluation.getValue().asStructure(); - assertEquals("on", structure.getValue("data").asString()); - - // Assert that the materialization repository was called with correct input - verify(mockRepository).loadMaterializedAssignmentsForUnit("test-user", "read-mat"); - } - - // add @Test and run it locally for load testing - public void testConcurrentResolveLoadTest() throws InterruptedException { - // Test configuration - final int totalResolves = 1000_000; - final int numThreads = 10; - final int resolvesPerThread = totalResolves / numThreads; - - // Create the provider using normal exampleState (not with materialization) - final OpenFeatureLocalResolveProvider provider = - new OpenFeatureLocalResolveProvider( - () -> ResolveTest.exampleStateBytes, - "", - TestBase.secret.getSecret(), - new ResolverFallback() { - @Override - public CompletableFuture resolve(ResolveFlagsRequest request) { - return CompletableFuture.completedFuture(null); - } - - @Override - public void close() {} - }); - - // Thread pool for executing concurrent resolves - final ExecutorService executorService = Executors.newFixedThreadPool(numThreads); - final CountDownLatch startLatch = new CountDownLatch(1); - final CountDownLatch completionLatch = new CountDownLatch(numThreads); - - // Track success and errors - final AtomicInteger successCount = new AtomicInteger(0); - final AtomicInteger errorCount = new AtomicInteger(0); - final List exceptions = new ArrayList<>(); - - // Submit tasks to thread pool - for (int i = 0; i < numThreads; i++) { - final int threadId = i; - executorService.submit( - () -> { - try { - // Wait for all threads to be ready - startLatch.await(); - - // Perform resolves - for (int j = 0; j < resolvesPerThread; j++) { - try { - final ProviderEvaluation evaluation = - provider.getObjectEvaluation( - "flag-1", - new Value("default"), - new ImmutableContext("user-" + threadId + "-" + j)); - - // Verify the resolve was successful (accept either variant) - final String variant = evaluation.getVariant(); - if (variant != null - && (variant.equals("flags/flag-1/variants/onnn") - || variant.equals("flags/flag-1/variants/offf"))) { - successCount.incrementAndGet(); - } else { - errorCount.incrementAndGet(); - } - } catch (Exception e) { - errorCount.incrementAndGet(); - synchronized (exceptions) { - exceptions.add(e); - } - } - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } finally { - completionLatch.countDown(); - } - }); - } - - // Start all threads at once - final long startTime = System.currentTimeMillis(); - startLatch.countDown(); - - // Wait for all threads to complete (with timeout) - final boolean completed = completionLatch.await(300, TimeUnit.SECONDS); - final long endTime = System.currentTimeMillis(); - final long durationMs = endTime - startTime; - - // Shutdown executor - executorService.shutdown(); - executorService.awaitTermination(5, TimeUnit.SECONDS); - - // Print test results - System.out.println("=== Load Test Results ==="); - System.out.println("Total resolves: " + totalResolves); - System.out.println("Threads: " + numThreads); - System.out.println("Duration: " + durationMs + "ms"); - System.out.println( - "Throughput: " + String.format("%.2f", (totalResolves * 1000.0 / durationMs)) + " req/s"); - System.out.println("Successful: " + successCount.get()); - System.out.println("Errors: " + errorCount.get()); - - // Assert test success - assertTrue(completed, "Load test did not complete within timeout"); - assertEquals(totalResolves, successCount.get(), "Not all resolves succeeded"); - assertEquals(0, errorCount.get(), "Unexpected errors occurred"); - assertTrue(exceptions.isEmpty(), "Exceptions occurred during load test: " + exceptions); - } -} diff --git a/pom.xml b/pom.xml index d6cba637..c653cdd2 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,6 @@ sdk-java confidence-proto openfeature-provider - openfeature-provider-local openfeature-provider-shared diff --git a/release-please-config.json b/release-please-config.json index a5bc968d..20a3e578 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -12,7 +12,6 @@ "extra-files": [ "pom.xml", "README.md", - "openfeature-provider-local/README.md", "openfeature-provider/pom.xml", "sdk-java/pom.xml", "sdk-java/src/main/java/com/spotify/confidence/ConfidenceUtils.java"