diff --git a/README.md b/README.md index e65b4c06..4c6fcc51 100644 --- a/README.md +++ b/README.md @@ -78,14 +78,14 @@ Add the following Maven dependency in your project's pom.xml file: io.harness ff-java-server-sdk - 1.6.1 + 1.7.0 ``` #### Gradle ``` -implementation 'io.harness:ff-java-server-sdk:1.6.1' +implementation 'io.harness:ff-java-server-sdk:1.7.0' ``` ### Code Sample diff --git a/build.gradle b/build.gradle index 8f6296a8..87aa10de 100644 --- a/build.gradle +++ b/build.gradle @@ -18,6 +18,7 @@ plugins { id 'jacoco-report-aggregation' id "com.github.spotbugs" version libs.versions.spotbugs id "org.owasp.dependencycheck" version libs.versions.depcheck + id 'me.champeau.jmh' version '0.6.8' // Added JMH plugin } @@ -55,6 +56,10 @@ dependencies { compileOnly libs.lombok annotationProcessor libs.lombok + + // JMH dependencies + implementation 'org.openjdk.jmh:jmh-core:1.37' + annotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:1.37' } group = 'io.harness' diff --git a/examples/build.gradle b/examples/build.gradle index 0d0ec470..d06ef1e3 100644 --- a/examples/build.gradle +++ b/examples/build.gradle @@ -18,3 +18,13 @@ tasks.register('GettingStarted', JavaExec) { classpath = sourceSets.main.runtimeClasspath mainClass = "io.harness.ff.examples.GettingStarted" } + +tasks.register('EventExample', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = "io.harness.ff.examples.EventExample" +} + +tasks.register('EventExampleWithFeatureSnapshot', JavaExec) { + classpath = sourceSets.main.runtimeClasspath + mainClass = "io.harness.ff.examples.EventExampleWithFeatureSnapshot" +} diff --git a/examples/src/main/java/io/harness/ff/examples/EventExampleWithFeatureSnapshot.java b/examples/src/main/java/io/harness/ff/examples/EventExampleWithFeatureSnapshot.java new file mode 100644 index 00000000..dcf25ed3 --- /dev/null +++ b/examples/src/main/java/io/harness/ff/examples/EventExampleWithFeatureSnapshot.java @@ -0,0 +1,106 @@ +package io.harness.ff.examples; + +import io.harness.cf.client.api.BaseConfig; +import io.harness.cf.client.api.CfClient; +import io.harness.cf.client.api.Event; +import io.harness.cf.client.api.XmlFileMapStore; +import io.harness.cf.client.connector.HarnessConfig; +import io.harness.cf.client.connector.HarnessConnector; +import io.harness.cf.client.dto.Target; +import io.harness.cf.model.FeatureSnapshot; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Slf4j +public class EventExampleWithFeatureSnapshot { + private static final String SDK_KEY = ""; + private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + + private static CfClient client; + + public static void main(String... args) { + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + scheduler.shutdown(); + client.close(); + })); + + final XmlFileMapStore fileStore = new XmlFileMapStore("Non-Freemium"); + // this is one way of initialising config. + final HarnessConnector hc = new HarnessConnector(SDK_KEY, HarnessConfig.builder().build()); + + BaseConfig bc = BaseConfig.builder(). + enableFeatureSnapshot(true).build(); + + // initialise the client. + client = new CfClient(hc, bc); + + client.on(Event.READY, result -> log.info("READY")); + + // example: specified prefix we can filter on. + final String FLAG_PREFIX = "FFM_"; + // example: given flag change event - get both previous and current feature if prefix is matched. + client.on(Event.CHANGED, identifier -> { + if (identifier.startsWith(FLAG_PREFIX)) { + getSnapshot(identifier); + } else { + log.info("We had an event change but flag did not have required prefix"); + } + }); + + // example : given flag change event - get all snapshots. + client.on(Event.CHANGED, identifier -> { + getAllSnapshots(); + }); + + // example : given flag change event - get all snapshots with given prefix. + client.on(Event.CHANGED, identifier -> { + getAllSnapshotsWithPrefix(); + }); + + final Target target = Target.builder().identifier("target1").attribute("testKey", "TestValue").name("target1").build(); + + scheduler.scheduleAtFixedRate(() -> { + log.info("ticking..."); + }, 0, 10, TimeUnit.SECONDS); + + } + + + // example method to extract a single snapshot. + private static void getSnapshot(String identifier) { + log.info("We had a chang event and prefix matched, lets inspect the diff"); + // fetch current and previous version of the feature + FeatureSnapshot snapshot = client.getFeatureSnapshot(identifier); + log.info("Previous flag config: {}, {}", identifier, snapshot.getPrevious()); + log.info("Current flag config: {}, {}", identifier, snapshot.getCurrent()); + } + + + // example method to extract and print all the snapshots. + private static void getAllSnapshots() { + // get all snapshots + List snapshots = client.getAllFeatureSnapshots(); + int counter = 0; + for (FeatureSnapshot snapshot : snapshots) { + log.info("snapshots {} {}", ++counter, snapshot); + } + } + + // example method to extract and print all the snapshots. + private static void getAllSnapshotsWithPrefix() { + // get all snapshots + String prefix = "FFM_"; + List snapshots = client.getAllFeatureSnapshots(prefix); + int counter = 0; + for (FeatureSnapshot snapshot : snapshots) { + log.info("snapshots {} {}", ++counter, snapshot); + } + } +} + + diff --git a/settings.gradle b/settings.gradle index 091df4c0..80326964 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,7 +4,7 @@ dependencyResolutionManagement { versionCatalogs { libs { // main sdk version - version('sdk', '1.6.1'); + version('sdk', '1.7.0'); // sdk deps version('okhttp3', '4.12.0') diff --git a/src/main/java/io/harness/cf/client/api/BaseConfig.java b/src/main/java/io/harness/cf/client/api/BaseConfig.java index cde8dee6..7986a6ef 100644 --- a/src/main/java/io/harness/cf/client/api/BaseConfig.java +++ b/src/main/java/io/harness/cf/client/api/BaseConfig.java @@ -42,6 +42,9 @@ public class BaseConfig { /** If metrics service POST call is taking > this time, we need to know about it */ @Builder.Default private final long metricsServiceAcceptableDuration = 10000; + /** store previous and current version of the FeatureConfig */ + @Builder.Default private final boolean enableFeatureSnapshot = false; + /** Get metrics post frequency in seconds */ public int getFrequency() { return Math.max(frequency, Config.MIN_FREQUENCY); diff --git a/src/main/java/io/harness/cf/client/api/CfClient.java b/src/main/java/io/harness/cf/client/api/CfClient.java index 07f580fb..111a5d00 100644 --- a/src/main/java/io/harness/cf/client/api/CfClient.java +++ b/src/main/java/io/harness/cf/client/api/CfClient.java @@ -5,6 +5,8 @@ import io.harness.cf.client.dto.Message; import io.harness.cf.client.dto.Target; import io.harness.cf.client.logger.LogUtil; +import io.harness.cf.model.FeatureSnapshot; +import java.util.List; import java.util.function.Consumer; import lombok.NonNull; @@ -83,6 +85,18 @@ public void on(@NonNull final Event event, @NonNull final Consumer consu client.on(event, consumer); } + public List getAllFeatureSnapshots(String prefix) { + return client.getFeatureSnapshots(prefix); + } + + public List getAllFeatureSnapshots() { + return client.getFeatureSnapshots(); + } + + public FeatureSnapshot getFeatureSnapshot(@NonNull String identifier) { + return client.getFeatureSnapshot(identifier); + } + public void off() { client.off(); } diff --git a/src/main/java/io/harness/cf/client/api/InnerClient.java b/src/main/java/io/harness/cf/client/api/InnerClient.java index 50be3524..bec1ed88 100644 --- a/src/main/java/io/harness/cf/client/api/InnerClient.java +++ b/src/main/java/io/harness/cf/client/api/InnerClient.java @@ -6,7 +6,10 @@ import io.harness.cf.client.dto.Message; import io.harness.cf.client.dto.Target; import io.harness.cf.model.FeatureConfig; +import io.harness.cf.model.FeatureSnapshot; import io.harness.cf.model.Variation; +import java.util.LinkedList; +import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; @@ -86,7 +89,9 @@ protected void setUp(@NonNull final Connector connector, @NonNull final BaseConf this.connector.setOnUnauthorized(this::onUnauthorized); // initialization - repository = new StorageRepository(options.getCache(), options.getStore(), this); + repository = + new StorageRepository( + options.getCache(), options.getStore(), this, options.isEnableFeatureSnapshot()); evaluator = new Evaluator(repository, options); authService = new AuthService(this.connector, options.getPollIntervalInSeconds(), this); pollProcessor = @@ -307,6 +312,32 @@ public synchronized void waitForInitialization() } } + public List getFeatureSnapshots() { + return getFeatureSnapshots(""); + } + + public List getFeatureSnapshots(String prefix) { + if (!options.isEnableFeatureSnapshot()) { + log.debug("FeatureSnapshot disabled, snapshot will contain only current version."); + } + List identifiers = repository.getAllFeatureIdentifiers(prefix); + List snapshots = new LinkedList<>(); + + for (String identifier : identifiers) { + FeatureSnapshot snapshot = getFeatureSnapshot(identifier); + snapshots.add(snapshot); + } + + return snapshots; + } + + public FeatureSnapshot getFeatureSnapshot(@NonNull String identifier) { + if (!options.isEnableFeatureSnapshot()) { + log.debug("FeatureSnapshot disabled, snapshot will contain only current version."); + } + return repository.getFeatureSnapshot(identifier); + } + public void on(@NonNull final Event event, @NonNull final Consumer consumer) { final CopyOnWriteArrayList> consumers = events.getOrDefault(event, new CopyOnWriteArrayList<>()); diff --git a/src/main/java/io/harness/cf/client/api/Query.java b/src/main/java/io/harness/cf/client/api/Query.java index 3df37e97..b8a09f4d 100644 --- a/src/main/java/io/harness/cf/client/api/Query.java +++ b/src/main/java/io/harness/cf/client/api/Query.java @@ -1,6 +1,7 @@ package io.harness.cf.client.api; import io.harness.cf.model.FeatureConfig; +import io.harness.cf.model.FeatureSnapshot; import io.harness.cf.model.Segment; import java.util.List; import java.util.Optional; @@ -13,4 +14,8 @@ public interface Query { Optional getSegment(@NonNull String identifier); List findFlagsBySegment(@NonNull String identifier); + + FeatureSnapshot getFeatureSnapshot(@NonNull String identifier); + + List getAllFeatureIdentifiers(String prefix); } diff --git a/src/main/java/io/harness/cf/client/api/StorageRepository.java b/src/main/java/io/harness/cf/client/api/StorageRepository.java index 840c3e45..0b819b1e 100644 --- a/src/main/java/io/harness/cf/client/api/StorageRepository.java +++ b/src/main/java/io/harness/cf/client/api/StorageRepository.java @@ -1,13 +1,11 @@ package io.harness.cf.client.api; +import com.google.gson.Gson; import io.harness.cf.client.common.Cache; import io.harness.cf.client.common.Storage; import io.harness.cf.client.common.Utils; import io.harness.cf.model.*; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Optional; +import java.util.*; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; @@ -18,13 +16,23 @@ class StorageRepository implements Repository { private Storage store; private final RepositoryCallback callback; - public StorageRepository(@NonNull Cache cache, RepositoryCallback callback) { + private final boolean cachePreviousFeatureConfigVersion; + + public StorageRepository( + @NonNull Cache cache, + RepositoryCallback callback, + boolean cachePreviousFeatureConfigVersion) { this.cache = cache; this.callback = callback; + this.cachePreviousFeatureConfigVersion = cachePreviousFeatureConfigVersion; } - public StorageRepository(@NonNull Cache cache, Storage store, RepositoryCallback callback) { - this(cache, callback); + public StorageRepository( + @NonNull Cache cache, + Storage store, + RepositoryCallback callback, + boolean cachePreviousFeatureConfigVersion) { + this(cache, callback, cachePreviousFeatureConfigVersion); this.store = store; } @@ -49,6 +57,79 @@ public Optional getFlag(@NonNull String identifier) { return getFlag(identifier, true); } + public List getAllFeatureIdentifiers(String prefix) { + List identifiers = new LinkedList<>(); + List keys = cache.keys(); + String flagPrefix = "flags/"; + for (String key : keys) { + if (key.startsWith(flagPrefix)) { + // Strip the flag prefix + String strippedKey = key.substring(flagPrefix.length()); + // If prefix is empty, add all stripped keys, otherwise check for prefix match + if (prefix.isEmpty() || strippedKey.startsWith(prefix)) { + identifiers.add(strippedKey); + } + } + } + return identifiers; + } + + public Optional getCurrentAndPreviousFeatureConfig(@NonNull String identifier) { + final String flagKey = formatFlagKey(identifier); + final String pFlagKey = formatPrevFlagKey(identifier); + + FeatureConfig pFlag = (FeatureConfig) cache.get(pFlagKey); + FeatureConfig cFlag = (FeatureConfig) cache.get(flagKey); + + if (cFlag != null) { + return Optional.of(new FeatureConfig[] {pFlag, cFlag}); + } + // if we don't have it in cache we check the file + if (this.store != null) { + pFlag = (FeatureConfig) store.get(pFlagKey); + cFlag = (FeatureConfig) store.get(flagKey); + if (pFlag != null) { + cache.set(pFlagKey, pFlag); + } + if (cFlag != null) { + cache.set(flagKey, cFlag); + } + return Optional.of(new FeatureConfig[] {pFlag, cFlag}); + } + return Optional.empty(); + } + + public FeatureSnapshot getFeatureSnapshot(@NonNull String identifier) { + Gson gson = new Gson(); + final String flagKey = formatFlagKey(identifier); + final String pFlagKey = formatPrevFlagKey(identifier); + + FeatureConfig pFlag = (FeatureConfig) cache.get(pFlagKey); + FeatureConfig cFlag = (FeatureConfig) cache.get(flagKey); + + if (cFlag != null) { + FeatureSnapshot deepCopySnapshot = + gson.fromJson(gson.toJson(new FeatureSnapshot(cFlag, pFlag)), FeatureSnapshot.class); + return deepCopySnapshot; + } + // if we don't have it in cache we check the file + if (this.store != null) { + pFlag = (FeatureConfig) store.get(pFlagKey); + cFlag = (FeatureConfig) store.get(flagKey); + if (pFlag != null) { + cache.set(pFlagKey, pFlag); + } + if (cFlag != null) { + cache.set(flagKey, cFlag); + } + + FeatureSnapshot deepCopySnapshot = + gson.fromJson(gson.toJson(new FeatureSnapshot(cFlag, pFlag)), FeatureSnapshot.class); + return deepCopySnapshot; + } + return null; + } + public Optional getSegment(@NonNull String identifier, boolean cacheable) { final String segmentKey = formatSegmentKey(identifier); Segment segment = (Segment) cache.get(segmentKey); @@ -109,15 +190,31 @@ public void setFlag(@NonNull String identifier, @NonNull FeatureConfig featureCo log.debug("Flag {} already exists", identifier); return; } + final String flagKey = formatFlagKey(identifier); + final Object previousFeatureConfig = store != null ? store.get(flagKey) : cache.get(flagKey); + + if (cachePreviousFeatureConfigVersion && previousFeatureConfig != null) { + final String previousFlagKey = formatPrevFlagKey(identifier); + if (store != null) { + store.set(previousFlagKey, previousFeatureConfig); + cache.delete(previousFlagKey); + log.debug("Flag {} successfully stored and cache invalidated", previousFlagKey); + } else { + cache.set(previousFlagKey, previousFeatureConfig); + } + log.debug("Flag {} successfully stored", previousFlagKey); + } + if (store != null) { store.set(flagKey, featureConfig); cache.delete(flagKey); - log.debug("Flag {} successfully stored and cache invalidated", identifier); } else { cache.set(flagKey, featureConfig); - log.debug("Flag {} successfully cached", identifier); } + + log.debug("Flag {} successfully stored", identifier); + if (callback != null) { callback.onFlagStored(identifier); } @@ -150,10 +247,17 @@ public void setSegment(@NonNull String identifier, @NonNull Segment segment) { @Override public void deleteFlag(@NonNull String identifier) { final String flagKey = this.formatFlagKey(identifier); + final String pflgKey = this.formatPrevFlagKey(identifier); if (store != null) { + if (cachePreviousFeatureConfigVersion) { + store.delete(pflgKey); + } store.delete(flagKey); log.debug("Flag {} successfully deleted from store", identifier); } + if (cachePreviousFeatureConfigVersion) { + this.cache.delete(pflgKey); + } this.cache.delete(flagKey); log.debug("Flag {} successfully deleted from cache", identifier); if (callback != null) { @@ -209,6 +313,10 @@ protected String formatFlagKey(@NonNull String identifier) { return String.format("flags/%s", identifier); } + protected String formatPrevFlagKey(@NonNull String identifier) { + return String.format("previous/%s", identifier); + } + @NonNull protected String formatSegmentKey(@NonNull String identifier) { return String.format("segments/%s", identifier); diff --git a/src/main/resources/client-v1.yaml b/src/main/resources/client-v1.yaml index f4643fe3..23c7661c 100644 --- a/src/main/resources/client-v1.yaml +++ b/src/main/resources/client-v1.yaml @@ -561,6 +561,13 @@ components: type: string required: - variation + FeatureSnapshot: + type: object + properties: + current: + $ref: '#/components/schemas/FeatureConfig' + previous: + $ref: '#/components/schemas/FeatureConfig' FeatureConfig: type: object properties: diff --git a/src/test/java/io/harness/cf/client/api/EvaluatorIntegrationTest.java b/src/test/java/io/harness/cf/client/api/EvaluatorIntegrationTest.java index dea8de92..3c1b52ca 100644 --- a/src/test/java/io/harness/cf/client/api/EvaluatorIntegrationTest.java +++ b/src/test/java/io/harness/cf/client/api/EvaluatorIntegrationTest.java @@ -82,7 +82,7 @@ public List getTestCases() throws Exception { removeExtension(file.getName())); final Repository repository = - new StorageRepository(new CaffeineCache(10000), null); + new StorageRepository(new CaffeineCache(10000), null, false); final Evaluator evaluator = new Evaluator(repository, Mockito.mock(BaseConfig.class)); diff --git a/src/test/java/io/harness/cf/client/api/EvaluatorTest.java b/src/test/java/io/harness/cf/client/api/EvaluatorTest.java index c4b3e01f..3e094515 100644 --- a/src/test/java/io/harness/cf/client/api/EvaluatorTest.java +++ b/src/test/java/io/harness/cf/client/api/EvaluatorTest.java @@ -34,7 +34,7 @@ public class EvaluatorTest { @BeforeAll public void setupUp() throws IOException, URISyntaxException { - final StorageRepository repository = new StorageRepository(new CaffeineCache(100), null, null); + final StorageRepository repository = new StorageRepository(new CaffeineCache(100), null, false); evaluator = new Evaluator(repository, Mockito.mock(BaseConfig.class)); loadSegments(repository, "local-test-cases/segments.json"); @@ -98,7 +98,7 @@ public void testTargetV2OrOperator(ORTest test) throws Exception { private void testTargetV2Operator(String email, String role, String flagName, String expected) throws Exception { - final StorageRepository repository = new StorageRepository(new CaffeineCache(100), null, null); + final StorageRepository repository = new StorageRepository(new CaffeineCache(100), null, false); final Evaluator evaluator = new Evaluator(repository, Mockito.mock(BaseConfig.class)); loadFlags(repository, "local-test-cases/v2-andor-flags.json"); @@ -259,7 +259,7 @@ public void shouldReturnCorrectAttrForGetAttrValue() { @Test public void shouldCorrectlyEvaluatePrereqsIfIdAndValueDiffer() throws Exception { - final StorageRepository repo = new StorageRepository(new CaffeineCache(100), null, null); + final StorageRepository repo = new StorageRepository(new CaffeineCache(100), null, false); final Evaluator eval = new Evaluator(repo, Mockito.mock(BaseConfig.class)); loadSegments(repo, "local-test-cases/segments.json"); @@ -298,7 +298,7 @@ public void testEvaluateRules() throws InterruptedException { final CountDownLatch latch = new CountDownLatch(threadCount); final ExecutorService executor = Executors.newFixedThreadPool(threadCount); final CopyOnWriteArrayList failures = new CopyOnWriteArrayList<>(); - final Query repository = new StorageRepository(new CaffeineCache(100), null, null); + final Query repository = new StorageRepository(new CaffeineCache(100), null, false); final Evaluator evaluator = new Evaluator(repository, Mockito.mock(BaseConfig.class)); final List values = new ArrayList<>(); diff --git a/src/test/java/io/harness/cf/client/api/StorageRepositoryTest.java b/src/test/java/io/harness/cf/client/api/StorageRepositoryTest.java new file mode 100644 index 00000000..27c77aa3 --- /dev/null +++ b/src/test/java/io/harness/cf/client/api/StorageRepositoryTest.java @@ -0,0 +1,271 @@ +package io.harness.cf.client.api; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.Gson; +import io.harness.cf.model.FeatureConfig; +import io.harness.cf.model.FeatureSnapshot; +import java.awt.*; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.LinkedList; +import java.util.List; +import java.util.stream.Stream; +import lombok.NonNull; +import org.junit.jupiter.api.Test; + +class StorageRepositoryTest { + private final Gson gson = new Gson(); + + @Test + void shouldInitialiseRepo() { + final Repository repository = new StorageRepository(new CaffeineCache(10000), null, false); + assertInstanceOf(StorageRepository.class, repository); + } + + @Test + void shouldStoreCurrentConfig() throws Exception { + final Repository repository = new StorageRepository(new CaffeineCache(10000), null, false); + assertInstanceOf(StorageRepository.class, repository); + + FeatureConfig featureConfig = GetFeatureConfigFromFile(); + FeatureConfig featureConfigUpdated = GetUpdatedFeatureConfigFromFile(); + + assertNotNull(featureConfig); + assertNotNull(featureConfigUpdated); + + loadFlags(repository, makeFeatureList(featureConfig)); + loadFlags(repository, makeFeatureList(featureConfigUpdated)); + + FeatureSnapshot res = repository.getFeatureSnapshot(featureConfigUpdated.getFeature()); + + FeatureConfig previous = res.getPrevious(); + FeatureConfig current = res.getCurrent(); + + // check if previous version is null + assertNull(previous); + assertNotNull(current); + + // check if the current version is correct + assertEquals(current.getVersion(), new Long(2)); + } + + @Test + void shouldStoreCurrentConfigWithFileStore() throws Exception { + + File file = File.createTempFile(FileMapStoreTest.class.getSimpleName(), ".tmp"); + file.deleteOnExit(); + + XmlFileMapStore store = new XmlFileMapStore(file.getAbsolutePath()); + + final Repository repository = + new StorageRepository(new CaffeineCache(10000), store, null, false); + assertInstanceOf(StorageRepository.class, repository); + + FeatureConfig featureConfig = GetFeatureConfigFromFile(); + FeatureConfig featureConfigUpdated = GetUpdatedFeatureConfigFromFile(); + + assertNotNull(featureConfig); + assertNotNull(featureConfigUpdated); + + loadFlags(repository, makeFeatureList(featureConfig)); + loadFlags(repository, makeFeatureList(featureConfigUpdated)); + + FeatureSnapshot res = repository.getFeatureSnapshot(featureConfigUpdated.getFeature()); + + FeatureConfig previous = res.getPrevious(); + FeatureConfig current = res.getCurrent(); + + // check if previous version is null + assertNull(previous); + assertNotNull(current); + + // check if the current version is correct + assertEquals(current.getVersion(), new Long(2)); + } + + @Test + void shouldStorePreviousAndCurrentConfigWithFileStore() throws Exception { + + File file = File.createTempFile(FileMapStoreTest.class.getSimpleName(), ".tmp"); + file.deleteOnExit(); + + XmlFileMapStore store = new XmlFileMapStore(file.getAbsolutePath()); + + final Repository repository = + new StorageRepository(new CaffeineCache(10000), store, null, true); + assertInstanceOf(StorageRepository.class, repository); + + FeatureConfig featureConfig = GetFeatureConfigFromFile(); + FeatureConfig featureConfigUpdated = GetUpdatedFeatureConfigFromFile(); + + assertNotNull(featureConfig); + assertNotNull(featureConfigUpdated); + + loadFlags(repository, makeFeatureList(featureConfig)); + loadFlags(repository, makeFeatureList(featureConfigUpdated)); + + FeatureSnapshot res = repository.getFeatureSnapshot(featureConfigUpdated.getFeature()); + + FeatureConfig previous = res.getPrevious(); + FeatureConfig current = res.getCurrent(); + + // check if previous version is null + assertNotNull(previous); + assertNotNull(current); + + // check if the current version is correct + assertEquals(previous.getVersion(), new Long(1)); + assertEquals(current.getVersion(), new Long(2)); + } + + @Test + void shouldStorePreviousAndCurrentConfig() throws Exception { + final Repository repository = new StorageRepository(new CaffeineCache(10000), null, true); + assertInstanceOf(StorageRepository.class, repository); + + FeatureConfig featureConfig = GetFeatureConfigFromFile(); + FeatureConfig featureConfigUpdated = GetUpdatedFeatureConfigFromFile(); + + assertNotNull(featureConfig); + assertNotNull(featureConfigUpdated); + + loadFlags(repository, makeFeatureList(featureConfig)); + loadFlags(repository, makeFeatureList(featureConfigUpdated)); + + FeatureSnapshot res = repository.getFeatureSnapshot(featureConfigUpdated.getFeature()); + + FeatureConfig previous = res.getPrevious(); + FeatureConfig current = res.getCurrent(); + + // check if previous version is null + assertNotNull(previous); + assertNotNull(current); + + // check if the current version is correct + assertEquals(previous.getVersion(), new Long(1)); + assertEquals(current.getVersion(), new Long(2)); + } + + @Test + void shouldDeletePreviousAndCurrentConfig() throws Exception { + final Repository repository = new StorageRepository(new CaffeineCache(10000), null, true); + assertInstanceOf(StorageRepository.class, repository); + + FeatureConfig featureConfig = GetFeatureConfigFromFile(); + FeatureConfig featureConfigUpdated = GetUpdatedFeatureConfigFromFile(); + + assertNotNull(featureConfig); + assertNotNull(featureConfigUpdated); + + loadFlags(repository, makeFeatureList(featureConfig)); + loadFlags(repository, makeFeatureList(featureConfigUpdated)); + + String featureIdentifier = featureConfig.getFeature(); + + FeatureSnapshot res = repository.getFeatureSnapshot(featureConfigUpdated.getFeature()); + + FeatureConfig previous = res.getPrevious(); + FeatureConfig current = res.getCurrent(); + + // check if previous version is null + assertNotNull(previous); + assertNotNull(current); + + // check if the current version is correct + assertEquals(previous.getVersion(), new Long(1)); + assertEquals(current.getVersion(), new Long(2)); + + // delete config + repository.deleteFlag(featureIdentifier); + FeatureSnapshot result = repository.getFeatureSnapshot(featureConfigUpdated.getFeature()); + + assertNull(result, "The Optional should be empty"); + } + + @Test + void shouldListAllKeysTest() throws Exception { + final Repository repository = new StorageRepository(new CaffeineCache(10000), null, true); + assertInstanceOf(StorageRepository.class, repository); + + FeatureConfig featureConfig = GetFeatureConfigFromFile(); + FeatureConfig featureConfigUpdated = GetUpdatedFeatureConfigFromFile(); + + assertNotNull(featureConfig); + assertNotNull(featureConfigUpdated); + + loadFlags(repository, makeFeatureList(featureConfig)); + loadFlags(repository, makeFeatureList(featureConfigUpdated)); + + List keys = repository.getAllFeatureIdentifiers(""); + assertEquals(keys.size(), 1); + assertEquals(keys.get(0), featureConfigUpdated.getFeature()); + } + + private void loadFlags(Repository repository, List flags) { + if (flags != null) { + for (FeatureConfig nextFlag : flags) { + repository.setFlag(nextFlag.getFeature(), nextFlag); + } + } + } + + @NonNull + private String read(@NonNull final String path) { + + final StringBuilder builder = new StringBuilder(); + try (final Stream stream = Files.lines(Paths.get(path), StandardCharsets.UTF_8)) { + stream.forEach(s -> builder.append(s).append("\n")); + } catch (IOException e) { + fail(e.getMessage()); + } + return builder.toString(); + } + + private List makeFeatureList(FeatureConfig fc) { + List fg = new LinkedList<>(); + fg.add(fc); + return fg; + } + + private FeatureConfig GetUpdatedFeatureConfigFromFile() throws Exception { + FeatureConfig fc = GetFeatureConfigFromFile(); + fc.setVersion(new Long(2)); + return fc; + } + + // get the flags and populate it. + private FeatureConfig GetFeatureConfigFromFile() throws Exception { + try { + String relativePath = + "./src/test/resources/local-test-cases/basic_bool_string_for_repository.json"; + // Resolve the absolute path + String filePath = new File(relativePath).getCanonicalPath(); + // Read the content of the file into a String + String jsonString = new String(Files.readAllBytes(Paths.get(filePath))); + final FeatureConfig featureConfig = gson.fromJson(jsonString, FeatureConfig.class); + return featureConfig; + + } catch (IOException e) { + // Handle exceptions like file not found or read errors + e.printStackTrace(); + } + return null; + } + + private List createBenchmarkData(int flagNumber, int version) throws Exception { + FeatureConfig fg = GetFeatureConfigFromFile(); + List list = new LinkedList(); + for (int i = 1; i <= flagNumber; i++) { + FeatureConfig f = fg; + f.setFeature("simpleBool" + i); + f.setVersion(new Long(version)); + list.add(f); + } + // System.out.println(list); + return list; + } +} diff --git a/src/test/java/io/harness/cf/client/api/StoreRepositoryBenchmark.java b/src/test/java/io/harness/cf/client/api/StoreRepositoryBenchmark.java new file mode 100644 index 00000000..f34ccc63 --- /dev/null +++ b/src/test/java/io/harness/cf/client/api/StoreRepositoryBenchmark.java @@ -0,0 +1,178 @@ +package io.harness.cf.client.api; + +import com.google.gson.Gson; +import io.harness.cf.model.FeatureConfig; +import io.harness.cf.model.FeatureSnapshot; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.*; + +/* + How to run it. + ./gradlew clean build + ./gradlew jmh + + some results: + Benchmark Mode Cnt Score Error Units + StoreRepositoryBenchmark.BenchmarkGetFeatureSnapshotsCurrentOnly avgt 5 805.511 ± 105.154 ms/op + StoreRepositoryBenchmark.BenchmarkGetFeatureSnapshotsPreviousAndCurrent avgt 5 860.745 ± 61.876 ms/op + StoreRepositoryBenchmark.BenchmarkLoadFeatureConfigCurrentOnly avgt 5 1.502 ± 0.198 ms/op + StoreRepositoryBenchmark.BenchmarkLoadFeatureConfigPreviousAndCurrent avgt 5 2.071 ± 0.650 ms/op + +*/ + +@State(Scope.Thread) +public class StoreRepositoryBenchmark { + + private Repository setCurrentOnlyRepository; + private Repository setCurrentAndPreviousRepository; + + private Repository getSnapshotCurrentOnlyRepository; + private Repository getSnapshotCurrentAndPreviousRepository; + private List featureConfigs; + private List updatedFeatureConfigs; + private final Gson gson = new Gson(); + private final int FeatureConfigSize = 10000; + private final int CacheSize = FeatureConfigSize * 2; + + @Setup + public void setup() throws Exception { + TestUtils tu = new TestUtils(); + featureConfigs = tu.CreateBenchmarkData(FeatureConfigSize, 1); + updatedFeatureConfigs = tu.CreateBenchmarkData(FeatureConfigSize, 2); + + setupRepoWithCurrentOnlyRepository(); + setupRepoWithCurrentAndPreviousRepository(); + setupRepoWithCurrentOnlyRepositoryForGetSnapshot(); + setupRepoWithCurrentAndPreviousRepositoryForGetSnapshot(); + } + + @Setup + public void setupRepoWithCurrentOnlyRepository() throws Exception { + + TestUtils tu = new TestUtils(); + setCurrentOnlyRepository = new StorageRepository(new CaffeineCache(CacheSize), null, false); + // Initial loading of feature configurations + loadFlags(setCurrentOnlyRepository, featureConfigs); + } + + @Setup + public void setupRepoWithCurrentAndPreviousRepository() throws Exception { + + TestUtils tu = new TestUtils(); + setCurrentAndPreviousRepository = + new StorageRepository(new CaffeineCache(CacheSize), null, true); + // Initial loading of feature configurations + loadFlags(setCurrentAndPreviousRepository, featureConfigs); + } + + @Setup + public void setupRepoWithCurrentOnlyRepositoryForGetSnapshot() throws Exception { + + TestUtils tu = new TestUtils(); + getSnapshotCurrentOnlyRepository = + new StorageRepository(new CaffeineCache(CacheSize), null, false); + // Initial loading of feature configurations + loadFlags(getSnapshotCurrentOnlyRepository, featureConfigs); + loadFlags(getSnapshotCurrentOnlyRepository, updatedFeatureConfigs); + } + + @Setup + public void setupRepoWithCurrentAndPreviousRepositoryForGetSnapshot() throws Exception { + TestUtils tu = new TestUtils(); + getSnapshotCurrentAndPreviousRepository = + new StorageRepository(new CaffeineCache(CacheSize), null, true); + // Initial loading of feature configurations + loadFlags(getSnapshotCurrentAndPreviousRepository, featureConfigs); + loadFlags(getSnapshotCurrentAndPreviousRepository, updatedFeatureConfigs); + } + + @Fork(value = 1, warmups = 1) + @OutputTimeUnit(TimeUnit.MILLISECONDS) + @Benchmark + @BenchmarkMode(Mode.AverageTime) + public void BenchmarkLoadFeatureConfigCurrentOnly() { + // Measure average time taken to load the updated feature configurations while storing only + // current snapshot + loadFlags(setCurrentOnlyRepository, updatedFeatureConfigs); + } + + @Fork(value = 1, warmups = 1) + @OutputTimeUnit(TimeUnit.MILLISECONDS) + @Benchmark + @BenchmarkMode(Mode.AverageTime) + public void BenchmarkLoadFeatureConfigPreviousAndCurrent() { + // Measure average time taken to load the updated feature configurations while storing current + // config as well as keeping previous one. + loadFlags(setCurrentAndPreviousRepository, updatedFeatureConfigs); + } + + @Fork(value = 1, warmups = 1) + @OutputTimeUnit(TimeUnit.MILLISECONDS) + @Benchmark + @BenchmarkMode(Mode.AverageTime) + public void BenchmarkGetFeatureSnapshotsCurrentOnly() { + // Measures time taken to get snapshots + List snapshots = getFeatureSnapshots(getSnapshotCurrentOnlyRepository); + if (snapshots == null) { + throw new IllegalStateException("Snapshots are null"); + } + if (snapshots.size() != FeatureConfigSize) { + throw new IllegalStateException("Snapshots not equal"); + } + + for (int i = 0; i < FeatureConfigSize; i++) { + FeatureSnapshot fs = snapshots.get(i); + if (fs.getPrevious() != null) { + throw new IllegalStateException("Snapshots contains previous"); + } + if (fs.getCurrent() == null) { + throw new IllegalStateException("Snapshots does not contain current"); + } + } + } + + @Fork(value = 1, warmups = 1) + @OutputTimeUnit(TimeUnit.MILLISECONDS) + @Benchmark + @BenchmarkMode(Mode.AverageTime) + public void BenchmarkGetFeatureSnapshotsPreviousAndCurrent() { + // Measures time taken to get snapshots + List snapshots = getFeatureSnapshots(getSnapshotCurrentAndPreviousRepository); + if (snapshots == null) { + throw new IllegalStateException("Snapshots are null"); + } + if (snapshots.size() != FeatureConfigSize) { + throw new IllegalStateException("Snapshots not equal"); + } + + for (int i = 0; i < FeatureConfigSize; i++) { + FeatureSnapshot fs = snapshots.get(i); + if (fs.getPrevious() == null) { + throw new IllegalStateException("Snapshots does not contain previous"); + } + if (fs.getCurrent() == null) { + throw new IllegalStateException("Snapshots does not contain current"); + } + } + } + + private List getFeatureSnapshots(Repository repository) { + List identifiers = repository.getAllFeatureIdentifiers(""); + List snapshots = new LinkedList<>(); + for (String identifier : identifiers) { + FeatureSnapshot snapshot = repository.getFeatureSnapshot(identifier); + snapshots.add(snapshot); + } + return snapshots; + } + + private void loadFlags(Repository repository, List flags) { + if (flags != null) { + for (FeatureConfig nextFlag : flags) { + repository.setFlag(nextFlag.getFeature(), nextFlag); + } + } + } +} diff --git a/src/test/java/io/harness/cf/client/api/TestUtils.java b/src/test/java/io/harness/cf/client/api/TestUtils.java index 61ce86ce..8f406e8c 100644 --- a/src/test/java/io/harness/cf/client/api/TestUtils.java +++ b/src/test/java/io/harness/cf/client/api/TestUtils.java @@ -1,10 +1,15 @@ package io.harness.cf.client.api; +import com.google.gson.Gson; +import io.harness.cf.model.FeatureConfig; +import java.io.File; import java.io.IOException; import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.LinkedList; +import java.util.List; public class TestUtils { @@ -20,8 +25,48 @@ public static String makeBasicFeatureJson() throws IOException, URISyntaxExcepti return getJsonResource("local-test-cases/basic_bool_string_number_json_variations.json"); } + public static String makeBasicFeatureJsonForRepoTest() throws IOException, URISyntaxException { + return getJsonResource("local-test-cases/basic_bool_string_for_repository.json"); + } + public static String getJsonResource(String location) throws IOException, URISyntaxException { final Path path = Paths.get(EvaluatorTest.class.getClassLoader().getResource(location).toURI()); return new String(Files.readAllBytes(path)); } + + private final Gson gson = new Gson(); + + public List CreateBenchmarkData(int size, int version) throws Exception { + FeatureConfig fg = GetFeatureConfigFromFile(); + List list = new LinkedList(); + for (int i = 1; i <= size; i++) { + FeatureConfig f = fg; + f.setFeature("simpleBool" + i); + f.setVersion(new Long(version)); + // we are copying objects + FeatureConfig df = gson.fromJson(gson.toJson(f), FeatureConfig.class); + list.add(df); + } + // System.out.println(list); + return list; + } + + public FeatureConfig GetFeatureConfigFromFile() throws Exception { + try { + String relativePath = + "./src/test/resources/local-test-cases/basic_bool_string_for_repository.json"; + // Resolve the absolute path + String filePath = new File(relativePath).getCanonicalPath(); + // Read the content of the file into a String + String jsonString = new String(Files.readAllBytes(Paths.get(filePath))); + + final FeatureConfig featureConfig = gson.fromJson(jsonString, FeatureConfig.class); + return featureConfig; + + } catch (IOException e) { + // Handle exceptions like file not found or read errors + e.printStackTrace(); + } + return null; + } } diff --git a/src/test/resources/local-test-cases/basic_bool_string_for_repository.json b/src/test/resources/local-test-cases/basic_bool_string_for_repository.json new file mode 100644 index 00000000..04830182 --- /dev/null +++ b/src/test/resources/local-test-cases/basic_bool_string_for_repository.json @@ -0,0 +1,28 @@ +{ + "defaultServe": { + "variation": "true" + }, + "environment": "TEST", + "feature": "simplebool", + "kind": "boolean", + "offVariation": "false", + "prerequisites": [], + "project": "test", + "rules": [], + "state": "on", + "variationToTargetMap": null, + "variations": [ + { + "identifier": "true", + "name": "True", + "value": "true" + }, + { + "identifier": "false", + "name": "False", + "value": "false" + } + ], + "version": 1 + } +