From f57648084b012c259b8567e79d5c4dab5056f06e Mon Sep 17 00:00:00 2001 From: Weihao Ding <158090588+weihao-statsig@users.noreply.github.com> Date: Fri, 18 Oct 2024 15:54:52 -0700 Subject: [PATCH] feat: re-init / manual sync config specs (#394) Please see this pr for details: https://github.com/statsig-io/private-java-server-sdk/pull/392 --- src/main/kotlin/com/statsig/sdk/Evaluator.kt | 7 ++ .../com/statsig/sdk/InitializationDetails.kt | 5 + src/main/kotlin/com/statsig/sdk/SpecStore.kt | 6 +- src/main/kotlin/com/statsig/sdk/Statsig.kt | 11 ++ .../kotlin/com/statsig/sdk/StatsigServer.kt | 27 +++++ .../java/com/statsig/sdk/APIOverrideTest.kt | 9 ++ .../java/com/statsig/sdk/StatsigE2ETest.kt | 1 + .../statsig/sdk/StatsigReinitializeTest.java | 101 ++++++++++++++++++ 8 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/statsig/sdk/StatsigReinitializeTest.java diff --git a/src/main/kotlin/com/statsig/sdk/Evaluator.kt b/src/main/kotlin/com/statsig/sdk/Evaluator.kt index a5b706a..7a2ef72 100644 --- a/src/main/kotlin/com/statsig/sdk/Evaluator.kt +++ b/src/main/kotlin/com/statsig/sdk/Evaluator.kt @@ -83,6 +83,13 @@ internal class Evaluator( specStore.shutdown() } + suspend fun syncConfigSpecs(): FailureDetails? { + if (!isInitialized) { + return FailureDetails(FailureReason.EMPTY_SPEC) + } + return specStore.syncConfigSpecs() + } + private fun createEvaluationDetails(reason: EvaluationReason): EvaluationDetails { if (reason == EvaluationReason.UNINITIALIZED) { return EvaluationDetails(0, 0, reason) diff --git a/src/main/kotlin/com/statsig/sdk/InitializationDetails.kt b/src/main/kotlin/com/statsig/sdk/InitializationDetails.kt index aa9b0df..7afc612 100644 --- a/src/main/kotlin/com/statsig/sdk/InitializationDetails.kt +++ b/src/main/kotlin/com/statsig/sdk/InitializationDetails.kt @@ -13,6 +13,11 @@ data class InitializationDetails( var failureDetails: FailureDetails? = null, ) +data class ConfigSyncDetails( + @SerializedName("details") + var details: InitializationDetails +) + data class FailureDetails( @SerializedName("reason") var reason: FailureReason, @SerializedName("exception") var exception: Exception? = null, diff --git a/src/main/kotlin/com/statsig/sdk/SpecStore.kt b/src/main/kotlin/com/statsig/sdk/SpecStore.kt index f2aa7b8..4224209 100644 --- a/src/main/kotlin/com/statsig/sdk/SpecStore.kt +++ b/src/main/kotlin/com/statsig/sdk/SpecStore.kt @@ -49,7 +49,7 @@ internal class SpecStore( if (!options.localMode) { specUpdater.initialize() - var failureDetails = this.initializeSpecs() + val failureDetails = this.initializeSpecs() this.initTime = if (specUpdater.lastUpdateTime == 0L) -1 else specUpdater.lastUpdateTime this.syncIdListsFromNetwork(specUpdater.updateIDLists()) @@ -67,6 +67,10 @@ internal class SpecStore( this.specUpdater.shutdown() } + suspend fun syncConfigSpecs(): FailureDetails? { + return initializeSpecs() + } + fun setDownloadedConfigs(downloadedConfig: APIDownloadedConfigs, isFromBootstrap: Boolean = false): Boolean { if (!downloadedConfig.hasUpdates) { return false diff --git a/src/main/kotlin/com/statsig/sdk/Statsig.kt b/src/main/kotlin/com/statsig/sdk/Statsig.kt index 40f8cbf..52eed36 100644 --- a/src/main/kotlin/com/statsig/sdk/Statsig.kt +++ b/src/main/kotlin/com/statsig/sdk/Statsig.kt @@ -784,6 +784,17 @@ class Statsig { runBlocking { statsigServer.shutdown() } } + /** + * Manually sync config specs if initialize is failed + */ + @JvmStatic + fun syncConfigSpecs(): CompletableFuture { + if (!checkInitialized()) { + return CompletableFuture.completedFuture(ConfigSyncDetails(InitializationDetails(0, false, false))) + } + return statsigServer.syncConfigSpecs() + } + @JvmStatic fun isInitialized(): Boolean { return ::statsigServer.isInitialized && statsigServer.initialized.get() diff --git a/src/main/kotlin/com/statsig/sdk/StatsigServer.kt b/src/main/kotlin/com/statsig/sdk/StatsigServer.kt index 68aeb27..0ce0708 100644 --- a/src/main/kotlin/com/statsig/sdk/StatsigServer.kt +++ b/src/main/kotlin/com/statsig/sdk/StatsigServer.kt @@ -46,6 +46,8 @@ sealed class StatsigServer { @JvmSynthetic abstract suspend fun manuallyLogConfigExposure(user: StatsigUser, configName: String) + abstract fun syncConfigSpecs(): CompletableFuture + @JvmSynthetic abstract suspend fun getExperiment(user: StatsigUser, experimentName: String): DynamicConfig @@ -583,6 +585,31 @@ private class StatsigServerImpl() : } } + override fun syncConfigSpecs(): CompletableFuture { + return statsigScope.future { + errorBoundary.capture("syncConfigSpecs", { + val failureDetails = evaluator.syncConfigSpecs() + ConfigSyncDetails( + InitializationDetails( + System.currentTimeMillis() - setupStartTime, + isSDKReady = isSDKInitialized(), + configSpecReady = failureDetails == null, + failureDetails, + ) + ) + }, { + ConfigSyncDetails( + InitializationDetails( + System.currentTimeMillis() - setupStartTime, + isSDKReady = isSDKInitialized(), + configSpecReady = false, + FailureDetails(FailureReason.INTERNAL_ERROR), + ) + ) + }) + } + } + override suspend fun getExperiment(user: StatsigUser, experimentName: String): DynamicConfig { if (!isSDKInitialized()) { return DynamicConfig.empty(experimentName) diff --git a/src/test/java/com/statsig/sdk/APIOverrideTest.kt b/src/test/java/com/statsig/sdk/APIOverrideTest.kt index e0c830c..5e67af6 100644 --- a/src/test/java/com/statsig/sdk/APIOverrideTest.kt +++ b/src/test/java/com/statsig/sdk/APIOverrideTest.kt @@ -5,6 +5,7 @@ import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest +import org.junit.After import org.junit.Before import org.junit.Test @@ -41,6 +42,7 @@ class APIOverrideTest { } } } + server2.apply { dispatcher = object : Dispatcher() { @Throws(InterruptedException::class) @@ -89,6 +91,13 @@ class APIOverrideTest { server3Calls.clear() } + @After + fun tearDown() { + server1.shutdown() + server2.shutdown() + server3.shutdown() + } + @Test fun testAPIOverride() { val options = StatsigOptions( diff --git a/src/test/java/com/statsig/sdk/StatsigE2ETest.kt b/src/test/java/com/statsig/sdk/StatsigE2ETest.kt index 8af7462..c295a53 100644 --- a/src/test/java/com/statsig/sdk/StatsigE2ETest.kt +++ b/src/test/java/com/statsig/sdk/StatsigE2ETest.kt @@ -204,6 +204,7 @@ class StatsigE2ETest { @After fun afterEach() { server.shutdown() + driver.shutdown() } @Test diff --git a/src/test/java/com/statsig/sdk/StatsigReinitializeTest.java b/src/test/java/com/statsig/sdk/StatsigReinitializeTest.java new file mode 100644 index 0000000..b326de8 --- /dev/null +++ b/src/test/java/com/statsig/sdk/StatsigReinitializeTest.java @@ -0,0 +1,101 @@ +package com.statsig.sdk; + +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.jetbrains.annotations.NotNull; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +public class StatsigReinitializeTest { + + private static final int MAX_CNT = 2; + + private MockWebServer server; + private StatsigOptions options; + private int requestCnt; + private String configSpecsResponse; + + @Before + public void setUp() throws IOException { + configSpecsResponse = new String(Files.readAllBytes(Paths.get(Objects.requireNonNull(getClass().getResource("/download_config_specs.json")).getPath()))); + server = new MockWebServer(); + requestCnt = 0; + server.setDispatcher(new Dispatcher() { + @NotNull + @Override + public MockResponse dispatch(@NotNull RecordedRequest request) { + // Simulate failure on first request, success on subsequent requests + if (request.getPath().contains("v1/download_config_specs")) { + if (requestCnt < MAX_CNT) { + requestCnt++; + return new MockResponse().setResponseCode(500); + } else { + return new MockResponse().setResponseCode(200).setBody(configSpecsResponse); + } + } + return new MockResponse().setResponseCode(200); + } + }); + + server.start(1080); + + options = new StatsigOptions(); + options.setApi(server.url("/v1").toString()); + options.setDisableDiagnostics(true); + } + + @After + public void tearDown() throws IOException { + server.shutdown(); + Statsig.shutdown(); + } + + @Test + public void testReinitialize() throws Exception { + CompletableFuture detailsFuture = Statsig.initializeAsync("server-secret", options); + StatsigUser user = new StatsigUser("123"); + user.setEmail("weihao@statsig.com"); + + InitializationDetails details = detailsFuture.get(); + Assert.assertNotNull(details); + Assert.assertTrue(details.isSDKReady()); + Assert.assertFalse(details.getConfigSpecReady()); // First init should fail due to 500 response + Assert.assertNotNull(details.getFailureDetails()); // Expect failure details for the first init + Assert.assertFalse(Statsig.checkGateSync(user, "always_on_gate")); + Assert.assertFalse(Statsig.checkGateSync(user, "on_for_statsig_email")); + Layer layer1 = Statsig.getLayerSync(user, "c_layer_with_holdout"); + Assert.assertEquals(layer1.getString("holdout_layer_param", "should_return_default"), "should_return_default"); + + + CompletableFuture details2Future = Statsig.syncConfigSpecs(); + ConfigSyncDetails details2 = details2Future.get(); + Assert.assertNotNull(details2); + Assert.assertTrue(details2.getDetails().isSDKReady()); + Assert.assertFalse(details2.getDetails().getConfigSpecReady()); // Second try should also fail + Assert.assertFalse(Statsig.checkGateSync(user, "always_on_gate")); + Assert.assertFalse(Statsig.checkGateSync(user, "on_for_statsig_email")); + Layer layer2 = Statsig.getLayerSync(user, "c_layer_with_holdout"); + Assert.assertEquals(layer2.getString("holdout_layer_param", "should_return_default"), "should_return_default"); + + CompletableFuture details3Future = Statsig.syncConfigSpecs(); + ConfigSyncDetails details3 = details3Future.get(); + Assert.assertNotNull(details3); + Assert.assertTrue(details3.getDetails().isSDKReady()); + Assert.assertTrue(details3.getDetails().getConfigSpecReady()); // Third try should succeed with 200 response + Assert.assertNull(details3.getDetails().getFailureDetails()); // No failure details expected for successful init + Assert.assertTrue(Statsig.checkGateSync(user, "always_on_gate")); + Assert.assertTrue(Statsig.checkGateSync(user, "on_for_statsig_email")); + Layer layer3 = Statsig.getLayerSync(user, "c_layer_with_holdout"); + Assert.assertEquals(layer3.getString("holdout_layer_param", "should_not_return_default"), "layer_default"); + } +}