diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..e48ea8ee --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "jvm-commons"] + path = jvm-commons + url = git@github.com:splitio/jvm-commons.git diff --git a/.harness/orgs/PROD/projects/Harness_Split/pipelines/java_client_ci_hc.yaml b/.harness/orgs/PROD/projects/Harness_Split/pipelines/java_client_ci_hc.yaml new file mode 100644 index 00000000..59c73c57 --- /dev/null +++ b/.harness/orgs/PROD/projects/Harness_Split/pipelines/java_client_ci_hc.yaml @@ -0,0 +1,160 @@ +pipeline: + name: java-client-ci-hc + identifier: java_client_ci_hc + projectIdentifier: Harness_Split + orgIdentifier: PROD + tags: {} + properties: + ci: + codebase: + repoName: java-client + build: <+input> + depth: 0 + stages: + - stage: + name: test + identifier: test + type: CI + spec: + cloneCodebase: true + caching: + enabled: true + override: true + platform: + os: Linux + arch: Amd64 + runtime: + type: Cloud + spec: {} + execution: + steps: + # jvm-commons submodule: init once, then install `events` into the local repo of each build step + - step: + type: Run + name: init submodules + identifier: init_submodules + spec: + connectorRef: dockerhub + image: maven:3.9-eclipse-temurin-8 + shell: Bash + command: |- + # .gitmodules points to GitHub for developers; CI pulls from the Harness Code mirror + git config submodule.jvm-commons.url https://git0.harness.io/l7B_kbSEQD2wjrM7PShm5w/PROD/Harness_Split/jvm-commons.git + git submodule update --init --recursive + # JDK 8: build + test + checkstyle (PR only) + - step: + type: Run + name: test jdk8 + identifier: test_jdk8 + spec: + connectorRef: dockerhub + image: maven:3.9-eclipse-temurin-8 + shell: Bash + envVariables: + MAVEN_OPTS: "-XX:InitialHeapSize=2G -XX:MaxHeapSize=2G -XX:+PrintCommandLineFlags -XX:ThreadStackSize=65536 -XX:-TieredCompilation -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn" + command: |- + apt-get update -qq && apt-get install -y --no-install-recommends redis-server + redis-server --daemonize yes + for i in $(seq 1 30); do + redis-cli ping 2>/dev/null | grep -q PONG && { echo "Redis is up"; break; } + echo "Waiting for Redis... ($i/30)"; sleep 1 + done + mkdir -p ${HOME}/.m2 && cp .ci.settings.xml ${HOME}/.m2/settings.xml + mvn --batch-mode -f jvm-commons/pom.xml -pl events -am install -DskipTests + mvn --batch-mode clean install + # print where surefire wrote the reports that get published below + find . -name 'TEST-*.xml' -path '*surefire-reports*' | head + reports: + type: JUnit + spec: + paths: + - "**/surefire-reports/*.xml" + when: + stageStatus: Success + condition: <+codebase.build.type> == "PR" + # JDK 17: e2e. Consumes the installed non-shaded artifact like a downstream app does (e2e/build.gradle + # runs `mvn install` first, so this step builds the SDK itself; steps do not share a local repo). + - step: + type: Run + name: e2e non-shaded jdk17 + identifier: e2e_non_shaded_jdk17 + spec: + connectorRef: dockerhub + image: maven:3.9-eclipse-temurin-17 + shell: Bash + envVariables: + MAVEN_OPTS: "-XX:InitialHeapSize=2G -XX:MaxHeapSize=2G -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn" + command: |- + mkdir -p ${HOME}/.m2 && cp .ci.settings.xml ${HOME}/.m2/settings.xml + # One explicit local repo for Maven and Gradle: in this container Maven's default repo (/harness/.m2) + # and the JVM's user.home (/root) disagree, so each tool would otherwise use a different one. + M2_REPO="${HOME}/.m2/repository" + mvn --batch-mode -f jvm-commons/pom.xml -pl events -am install -DskipTests -Dmaven.repo.local="${M2_REPO}" + cd e2e && ./gradlew test --console=plain -Dmaven.repo.local="${M2_REPO}" + reports: + type: JUnit + spec: + paths: + - "e2e/build/test-results/*/*.xml" + when: + stageStatus: Success + condition: <+codebase.build.type> == "PR" + # JDK 8: checkstyle (PR only) + - step: + type: Run + name: checkstyle jdk8 + identifier: checkstyle_jdk8 + spec: + connectorRef: dockerhub + image: maven:3.9-eclipse-temurin-8 + shell: Bash + envVariables: + MAVEN_OPTS: "-XX:InitialHeapSize=2G -XX:MaxHeapSize=2G -XX:+PrintCommandLineFlags -XX:ThreadStackSize=65536 -XX:-TieredCompilation -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn" + command: |- + mkdir -p ${HOME}/.m2 && cp .ci.settings.xml ${HOME}/.m2/settings.xml + mvn --batch-mode -f jvm-commons/pom.xml -pl events -am install -DskipTests + mvn --batch-mode checkstyle:check + when: + stageStatus: Success + condition: <+codebase.build.type> == "PR" + # JDK 11: Sonar (PR and push to master/development) + - step: + type: Run + name: sonar analysis + identifier: sonar_analysis + spec: + connectorRef: dockerhub + image: maven:3.9-eclipse-temurin-11 + shell: Bash + envVariables: + MAVEN_OPTS: "-XX:InitialHeapSize=2G -XX:MaxHeapSize=2G -XX:+PrintCommandLineFlags -XX:ThreadStackSize=65536 -XX:-TieredCompilation -Dorg.slf4j.simpleLogger.log.org.apache.maven.cli.transfer.Slf4jMavenTransferListener=warn" + SONAR_HOST_URL: https://sonar.harness.io + SONAR_TOKEN: <+secrets.getValue("sonarqube-token")> + PR_NUMBER: <+trigger.prNumber> + PR_BRANCH: <+trigger.sourceBranch> + PR_BASE_BRANCH: <+trigger.targetBranch> + BRANCH_NAME: <+trigger.branch> + command: |- + apt-get update -qq && apt-get install -y --no-install-recommends redis-server + redis-server --daemonize yes + for i in $(seq 1 30); do + redis-cli ping 2>/dev/null | grep -q PONG && { echo "Redis is up"; break; } + echo "Waiting for Redis... ($i/30)"; sleep 1 + done + mkdir -p ${HOME}/.m2 && cp .ci.settings.xml ${HOME}/.m2/settings.xml + mvn --batch-mode -f jvm-commons/pom.xml -pl events -am install -DskipTests + if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then + mvn --batch-mode clean verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar \ + -Dsonar.host.url="$SONAR_HOST_URL" \ + -Dsonar.token="$SONAR_TOKEN" \ + -Dsonar.pullrequest.key="$PR_NUMBER" \ + -Dsonar.pullrequest.branch="$PR_BRANCH" \ + -Dsonar.pullrequest.base="$PR_BASE_BRANCH" + else + mvn --batch-mode clean verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar \ + -Dsonar.host.url="$SONAR_HOST_URL" \ + -Dsonar.token="$SONAR_TOKEN" \ + -Dsonar.branch.name="$BRANCH_NAME" + fi + when: + stageStatus: Success diff --git a/CHANGES.txt b/CHANGES.txt index 90d7a41f..b2336f8e 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,8 @@ +4.19.0 (Oct 8, 2026) + - Added the ability to listen to different events triggered by the SDK. Read more in our docs. + - SDK_UPDATE notify when a flag or user segment has changed + - SDK_READY notify when the SDK is ready to evaluate + 4.18.4 (Oct 1, 2026) - Updated `org.apache.httpcomponents.client5` dependency to 5.6.4 to address CVE-2026-71290. diff --git a/client/pom.xml b/client/pom.xml index 173b4360..de4c47cf 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -5,10 +5,9 @@ io.split.client java-client-parent - 4.18.4 + 4.19.0 - - 4.18.4 + 4.19.0 java-client jar Java Client @@ -48,22 +47,15 @@ ${maven.compiler.target} + org.apache.maven.plugins maven-jar-plugin 3.3.0 - - - non-shaded-jar - package - - jar - - - non-shaded - - - + + true + @@ -72,7 +64,46 @@ maven-shade-plugin 3.2.4 + + + shade-non-shaded + package + + shade + + + true + non-shaded + false + + + io.split.client:java-client + io.harness:events + + + + + io.harness.events + split.io.harness.events + + + + + io.harness:events + + META-INF/** + + + + + + shade-fat package shade @@ -84,6 +115,7 @@ io.split.engine:* io.split.client:* + io.harness:events io.split.schemas:* io.codigo.grammar:* org.apache.httpcomponents.* @@ -104,6 +136,10 @@ + + io.harness.events + split.io.harness.events + org.apache split.org.apache @@ -167,6 +203,11 @@ + + io.harness + events + 1.0.0 + io.split.client pluggable-storage diff --git a/client/src/main/java/io/split/client/SplitClient.java b/client/src/main/java/io/split/client/SplitClient.java index 3a21ca1b..22838078 100644 --- a/client/src/main/java/io/split/client/SplitClient.java +++ b/client/src/main/java/io/split/client/SplitClient.java @@ -1,6 +1,8 @@ package io.split.client; import io.split.client.api.Key; +import io.split.client.api.SdkEvent; +import io.split.client.api.SdkEventListener; import io.split.client.api.SplitResult; import io.split.client.dtos.EvaluationOptions; @@ -951,6 +953,53 @@ Map getTreatmentsWithConfigByFlagSets(Key key, List */ void destroy(); + /** + * Registers {@code callback} to run when {@code event} fires, ignoring the event details. + * Equivalent to {@link #on(SdkEvent, SdkEventListener)} with a listener that discards its metadata. + * + * @param event the event to subscribe to. If null a warning is logged and the call is ignored. + * @param callback invoked each time the event fires. If null a warning is logged and the call is ignored. + * @see #on(SdkEvent, SdkEventListener) + */ + void on(SdkEvent event, Runnable callback); + + /** + * Registers {@code listener} to be invoked when {@code event} fires. + * + *

Any number of listeners can be registered for the same event, including the same listener more than once; + * each registration is invoked, in registration order. Listeners run on an SDK-owned thread, never on the caller's + * thread, and an exception thrown by one listener does not prevent the others from running. + * + *

Per event: + *

    + *
  • {@link SdkEvent#SDK_READY}: fires once, when feature flags and segments are available. Metadata has + * {@code isInitialCacheLoad()} and {@code getLastUpdateTimestamp()} populated (the timestamp may be null); + * {@code getType()} is null and {@code getNames()} is empty. A listener registered after the event already + * fired is still invoked once, asynchronously, with {@code null} metadata.
  • + *
  • {@link SdkEvent#SDK_READY_TIMED_OUT}: fires once if {@link #blockUntilReady()} times out, and is not + * emitted if the SDK became ready first. Metadata is always {@code null}; it is replayed to late + * subscribers like {@code SDK_READY}.
  • + *
  • {@link SdkEvent#SDK_UPDATE}: fires every time feature flags or segments change after the SDK became + * ready. Metadata has {@code getType()} and {@code getNames()} populated (the flag names for a flag update; + * empty for segment updates). It is never replayed to late subscribers. It is not available in consumer + * mode, where no update event is ever produced.
  • + *
+ * + *

Registering after {@link #destroy()} is a silent no-op. + * + * @param event the event to subscribe to. If null a warning is logged and the call is ignored. + * @param listener invoked when the event fires. If null a warning is logged and the call is ignored. + */ + void on(SdkEvent event, SdkEventListener listener); + + /** + * Removes every listener registered for {@code event}. Other events are not affected, and new listeners can + * still be registered for {@code event} afterwards. + * + * @param event the event whose listeners are removed. If null a warning is logged and the call is ignored. + */ + void off(SdkEvent event); + /** * Enqueue a new event to be sent to split data collection services * diff --git a/client/src/main/java/io/split/client/SplitClientImpl.java b/client/src/main/java/io/split/client/SplitClientImpl.java index 38ab5e6b..2c55729a 100644 --- a/client/src/main/java/io/split/client/SplitClientImpl.java +++ b/client/src/main/java/io/split/client/SplitClientImpl.java @@ -2,10 +2,14 @@ import com.google.gson.GsonBuilder; import io.split.client.api.Key; +import io.split.client.api.SdkEvent; +import io.split.client.api.SdkEventListener; import io.split.client.api.SplitResult; import io.split.client.dtos.*; import io.split.client.events.EventsStorageProducer; import io.split.client.impressions.Impression; +import io.split.client.lifecycle.SdkEventsManager; +import io.split.client.lifecycle.SdkInternalEvent; import io.split.client.impressions.ImpressionsManager; import io.split.client.interceptors.FlagSetsFilter; import io.split.engine.SDKReadinessGates; @@ -53,6 +57,7 @@ public final class SplitClientImpl implements SplitClient { private static final Logger _log = LoggerFactory.getLogger(SplitClientImpl.class); private final SplitFactory _container; + private final SdkEventsManager _eventsManager; private final SplitCacheConsumer _splitCacheConsumer; private final ImpressionsManager _impressionManager; private final SplitClientConfig _config; @@ -75,7 +80,25 @@ public SplitClientImpl(SplitFactory container, TelemetryConfigProducer telemetryConfigProducer, FlagSetsFilter flagSetsFilter, FallbackTreatmentCalculator fallbackTreatmentCalculator) { + this(container, splitCacheConsumer, impressionManager, eventsStorageProducer, config, gates, evaluator, + telemetryEvaluationProducer, telemetryConfigProducer, flagSetsFilter, fallbackTreatmentCalculator, null); + } + + + public SplitClientImpl(SplitFactory container, + SplitCacheConsumer splitCacheConsumer, + ImpressionsManager impressionManager, + EventsStorageProducer eventsStorageProducer, + SplitClientConfig config, + SDKReadinessGates gates, + Evaluator evaluator, + TelemetryEvaluationProducer telemetryEvaluationProducer, + TelemetryConfigProducer telemetryConfigProducer, + FlagSetsFilter flagSetsFilter, + FallbackTreatmentCalculator fallbackTreatmentCalculator, + SdkEventsManager eventsManager) { _container = container; + _eventsManager = eventsManager; _splitCacheConsumer = checkNotNull(splitCacheConsumer); _impressionManager = checkNotNull(impressionManager); _eventsStorageProducer = eventsStorageProducer; @@ -437,6 +460,9 @@ public void blockUntilReady() throws TimeoutException, InterruptedException { throw new IllegalArgumentException("setBlockUntilReadyTimeout must be positive but in config was: " + _config.blockUntilReady()); } if (!_gates.waitUntilInternalReady(_config.blockUntilReady())) { + if (_eventsManager != null) { + _eventsManager.notifyInternalEvent(SdkInternalEvent.SDK_READY_TIMEOUT_REACHED, null); + } throw new TimeoutException("SDK was not ready in " + _config.blockUntilReady() + " milliseconds"); } _log.debug(String.format("Split SDK ready in %d ms", (System.currentTimeMillis() - startTime))); @@ -447,6 +473,41 @@ public void destroy() { _container.destroy(); } + @Override + public void on(SdkEvent event, Runnable callback) { + if (callback == null) { + _log.warn("on: callback must not be null, the registration was ignored"); + return; + } + on(event, metadata -> callback.run()); + } + + @Override + public void on(SdkEvent event, SdkEventListener listener) { + if (event == null) { + _log.warn("on: event must not be null, the registration was ignored"); + return; + } + if (listener == null) { + _log.warn("on: listener must not be null, the registration was ignored"); + return; + } + if (_eventsManager != null) { + _eventsManager.on(event, listener); + } + } + + @Override + public void off(SdkEvent event) { + if (event == null) { + _log.warn("off: event must not be null, the call was ignored"); + return; + } + if (_eventsManager != null) { + _eventsManager.off(event); + } + } + private boolean track(Event event) { long initTime = System.currentTimeMillis(); if (_container.isDestroyed()) { diff --git a/client/src/main/java/io/split/client/SplitFactoryImpl.java b/client/src/main/java/io/split/client/SplitFactoryImpl.java index 9ad38ef1..1da1d1f8 100644 --- a/client/src/main/java/io/split/client/SplitFactoryImpl.java +++ b/client/src/main/java/io/split/client/SplitFactoryImpl.java @@ -40,6 +40,10 @@ import io.split.client.utils.SDKMetadata; import io.split.client.utils.StaticContentInputStreamProvider; import io.split.engine.SDKReadinessGates; +import io.split.client.api.SdkEventMetadata; +import io.split.client.lifecycle.SdkEventsManager; +import io.split.client.lifecycle.SdkEventsNotifier; +import io.split.client.lifecycle.SdkInternalEvent; import io.split.engine.common.ConsumerSyncManager; import io.split.engine.common.ConsumerSynchronizer; import io.split.engine.common.LocalhostSyncManager; @@ -167,6 +171,7 @@ public class SplitFactoryImpl implements SplitFactory { private final SplitSynchronizationTask _splitSynchronizationTask; private final EventsTask _eventsTask; private final SyncManager _syncManager; + private final SdkEventsManager _eventsManager; private SplitHttpClient _splitHttpClient; private final UserStorageWrapper _userStorageWrapper; private final ImpressionsSender _impressionsSender; @@ -223,14 +228,18 @@ public SplitFactoryImpl(String apiToken, SplitClientConfig config) throws URISyn telemetryStorage, splitCache, _segmentCache, telemetryStorage, _startTime); + // Events manager: must exist (dispatchers registered) before any sync task can start, so SDK_READY can't be lost. + _eventsManager = SdkEventsManager.create(config.getThreadFactory()); + SdkEventsNotifier notifier = _eventsManager::notifyInternalEvent; + // Segments - _segmentSynchronizationTaskImp = buildSegments(config, segmentCache, splitCache, ruleBasedSegmentCache); + _segmentSynchronizationTaskImp = buildSegments(config, segmentCache, splitCache, ruleBasedSegmentCache, notifier); SplitParser splitParser = new SplitParser(); RuleBasedSegmentParser ruleBasedSegmentParser = new RuleBasedSegmentParser(); // SplitFetcher _splitFetcher = buildSplitFetcher(splitCache, splitParser, flagSetsFilter, - ruleBasedSegmentParser, ruleBasedSegmentCache, config.isSdkEndpointOverridden()); + ruleBasedSegmentParser, ruleBasedSegmentCache, config.isSdkEndpointOverridden(), notifier); // SplitSynchronizationTask _splitSynchronizationTask = new SplitSynchronizationTask(_splitFetcher, @@ -272,11 +281,12 @@ public SplitFactoryImpl(String apiToken, SplitClientConfig config) throws URISyn _telemetryStorageProducer, // TelemetryEvaluation instance _telemetryStorageProducer, // TelemetryConfiguration instance flagSetsFilter, - fallbackTreatmentCalculatorImp + fallbackTreatmentCalculatorImp, + _eventsManager ); // SplitManager - _manager = new SplitManagerImpl(splitCache, config, _gates, _telemetryStorageProducer); + _manager = new SplitManagerImpl(splitCache, config, _gates, _telemetryStorageProducer, notifier); // SyncManager SplitTasks splitTasks = SplitTasks.build(_splitSynchronizationTask, _segmentSynchronizationTaskImp, @@ -286,7 +296,7 @@ public SplitFactoryImpl(String apiToken, SplitClientConfig config) throws URISyn _syncManager = SyncManagerImp.build(splitTasks, _splitFetcher, splitCache, splitAPI, segmentCache, _gates, _telemetryStorageProducer, _telemetrySynchronizer, config, splitParser, - ruleBasedSegmentParser, flagSetsFilter, ruleBasedSegmentCache); + ruleBasedSegmentParser, flagSetsFilter, ruleBasedSegmentCache, notifier); _syncManager.start(); // DestroyOnShutDown @@ -349,6 +359,10 @@ protected SplitFactoryImpl(String apiToken, SplitClientConfig config, CustomStor // SDKReadinessGates _gates = new SDKReadinessGates(); + // Events manager. Consumer mode has no sync engine, so nothing is wired to notify it: SDK_UPDATE is not + // supported here (spec p.3) and only SDK_READY is produced (FME-5). + _eventsManager = SdkEventsManager.create(config.getThreadFactory()); + _telemetrySynchronizer = new TelemetryConsumerSubmitter(customStorageWrapper, _sdkMetadata); UserCustomRuleBasedSegmentAdapterConsumer userCustomRuleBasedSegmentAdapterConsumer = new UserCustomRuleBasedSegmentAdapterConsumer(customStorageWrapper); @@ -384,14 +398,16 @@ protected SplitFactoryImpl(String apiToken, SplitClientConfig config, CustomStor _telemetryStorageProducer, // TelemetryEvaluation instance _telemetryStorageProducer, // TelemetryConfiguration instance flagSetsFilter, - fallbackTreatmentCalculatorImp + fallbackTreatmentCalculatorImp, + _eventsManager ); // SyncManager _syncManager = new ConsumerSyncManager(synchronizer); _syncManager.start(); - _manager = new SplitManagerImpl(userCustomSplitAdapterConsumer, config, _gates, _telemetryStorageProducer); + _manager = new SplitManagerImpl(userCustomSplitAdapterConsumer, config, _gates, _telemetryStorageProducer, + _eventsManager::notifyInternalEvent); manageSdkReady(config); } @@ -420,6 +436,10 @@ protected SplitFactoryImpl(SplitClientConfig config) { _gates = new SDKReadinessGates(); _segmentCache = segmentCache; + // Events manager: constructed before any sync task starts. + _eventsManager = SdkEventsManager.create(config.getThreadFactory()); + SdkEventsNotifier notifier = _eventsManager::notifyInternalEvent; + // SegmentFetcher SegmentChangeFetcher segmentChangeFetcher = new LocalhostSegmentFetcherNoop(); @@ -434,7 +454,8 @@ protected SplitFactoryImpl(SplitClientConfig config) { _telemetryStorageProducer, _splitCache, config.getThreadFactory(), - ruleBasedSegmentCache); + ruleBasedSegmentCache, + notifier); // SplitFetcher SplitChangeFetcher splitChangeFetcher = createSplitChangeFetcher(config); @@ -442,7 +463,7 @@ protected SplitFactoryImpl(SplitClientConfig config) { RuleBasedSegmentParser ruleBasedSegmentParser = new RuleBasedSegmentParser(); _splitFetcher = new SplitFetcherImp(splitChangeFetcher, splitParser, splitCache, _telemetryStorageProducer, - flagSetsFilter, ruleBasedSegmentParser, ruleBasedSegmentCache); + flagSetsFilter, ruleBasedSegmentParser, ruleBasedSegmentCache, notifier); // SplitSynchronizationTask _splitSynchronizationTask = new SplitSynchronizationTask(_splitFetcher, splitCache, @@ -470,7 +491,8 @@ protected SplitFactoryImpl(SplitClientConfig config) { _telemetryStorageProducer, // TelemetryEvaluation instance _telemetryStorageProducer, // TelemetryConfiguration instance flagSetsFilter, - fallbackTreatmentCalculatorImp + fallbackTreatmentCalculatorImp, + _eventsManager ); // Synchronizer @@ -478,9 +500,9 @@ protected SplitFactoryImpl(SplitClientConfig config) { config.localhostRefreshEnabled()); // SplitManager - _manager = new SplitManagerImpl(splitCache, config, _gates, _telemetryStorageProducer); + _manager = new SplitManagerImpl(splitCache, config, _gates, _telemetryStorageProducer, notifier); // SyncManager - _syncManager = new LocalhostSyncManager(synchronizer, _gates); + _syncManager = new LocalhostSyncManager(synchronizer, _gates, notifier); _syncManager.start(); // DestroyOnShutDown @@ -511,6 +533,7 @@ public synchronized void destroy() { } try { _log.info("Shutdown called for split"); + _eventsManager.destroy(); _syncManager.shutdown(); _log.info("Successful shutdown of syncManager"); if (OperationMode.STANDALONE.equals(_operationMode)) { @@ -692,7 +715,8 @@ private static HttpClientBuilder addProxyBasicAuth(HttpClientBuilder httpClientb private SegmentSynchronizationTaskImp buildSegments(SplitClientConfig config, SegmentCacheProducer segmentCacheProducer, - SplitCacheConsumer splitCacheConsumer, RuleBasedSegmentCacheConsumer ruleBasedSegmentCache) throws URISyntaxException { + SplitCacheConsumer splitCacheConsumer, RuleBasedSegmentCacheConsumer ruleBasedSegmentCache, + SdkEventsNotifier notifier) throws URISyntaxException { SegmentChangeFetcher segmentChangeFetcher = HttpSegmentChangeFetcher.create(_splitHttpClient, _rootTarget, _telemetryStorageProducer); @@ -703,16 +727,18 @@ private SegmentSynchronizationTaskImp buildSegments(SplitClientConfig config, _telemetryStorageProducer, splitCacheConsumer, config.getThreadFactory(), - ruleBasedSegmentCache); + ruleBasedSegmentCache, + notifier); } private SplitFetcher buildSplitFetcher(SplitCacheProducer splitCacheProducer, SplitParser splitParser, FlagSetsFilter flagSetsFilter, RuleBasedSegmentParser ruleBasedSegmentParser, - RuleBasedSegmentCacheProducer ruleBasedSegmentCache, boolean isRootURIOverriden) throws URISyntaxException { + RuleBasedSegmentCacheProducer ruleBasedSegmentCache, boolean isRootURIOverriden, + SdkEventsNotifier notifier) throws URISyntaxException { SplitChangeFetcher splitChangeFetcher = HttpSplitChangeFetcher.create(_splitHttpClient, _rootTarget, _telemetryStorageProducer, isRootURIOverriden); return new SplitFetcherImp(splitChangeFetcher, splitParser, splitCacheProducer, _telemetryStorageProducer, - flagSetsFilter, ruleBasedSegmentParser, ruleBasedSegmentCache); + flagSetsFilter, ruleBasedSegmentParser, ruleBasedSegmentCache, notifier); } private ImpressionsManagerImpl buildImpressionsManager(SplitClientConfig config, @@ -783,6 +809,7 @@ private void manageSdkReady(SplitClientConfig config) { } } _gates.sdkInternalReady(); + _eventsManager.notifyInternalEvent(SdkInternalEvent.SDK_READY, SdkEventMetadata.ready(false, null)); _telemetrySynchronizer.synchronizeConfig(config, System.currentTimeMillis(), ApiKeyCounter.getApiKeyCounterInstance().getFactoryInstances(), new ArrayList<>()); }); diff --git a/client/src/main/java/io/split/client/SplitManagerImpl.java b/client/src/main/java/io/split/client/SplitManagerImpl.java index d3e69cf7..8ada5931 100644 --- a/client/src/main/java/io/split/client/SplitManagerImpl.java +++ b/client/src/main/java/io/split/client/SplitManagerImpl.java @@ -2,6 +2,8 @@ import com.google.common.base.Preconditions; import io.split.client.api.SplitView; +import io.split.client.lifecycle.SdkEventsNotifier; +import io.split.client.lifecycle.SdkInternalEvent; import io.split.engine.SDKReadinessGates; import io.split.engine.experiments.ParsedSplit; import io.split.inputValidation.SplitNameValidator; @@ -26,6 +28,7 @@ public class SplitManagerImpl implements SplitManager { private final SplitCacheConsumer _splitCacheConsumer; private final SplitClientConfig _config; + private final SdkEventsNotifier _notifier; private final SDKReadinessGates _gates; private final TelemetryConfigProducer _telemetryConfigProducer; @@ -34,6 +37,15 @@ public SplitManagerImpl(SplitCacheConsumer splitCacheConsumer, SplitClientConfig config, SDKReadinessGates gates, TelemetryConfigProducer telemetryConfigProducer) { + this(splitCacheConsumer, config, gates, telemetryConfigProducer, SdkEventsNotifier.NOOP); + } + + public SplitManagerImpl(SplitCacheConsumer splitCacheConsumer, + SplitClientConfig config, + SDKReadinessGates gates, + TelemetryConfigProducer telemetryConfigProducer, + SdkEventsNotifier notifier) { + _notifier = Preconditions.checkNotNull(notifier); _config = Preconditions.checkNotNull(config); _splitCacheConsumer = Preconditions.checkNotNull(splitCacheConsumer); _gates = Preconditions.checkNotNull(gates); @@ -96,6 +108,7 @@ public void blockUntilReady() throws TimeoutException, InterruptedException { } if (!_gates.waitUntilInternalReady(_config.blockUntilReady())) { _telemetryConfigProducer.recordBURTimeout(); + _notifier.notify(SdkInternalEvent.SDK_READY_TIMEOUT_REACHED, null); throw new TimeoutException("SDK was not ready in " + _config.blockUntilReady()+ " milliseconds"); } } diff --git a/client/src/main/java/io/split/client/api/SdkEvent.java b/client/src/main/java/io/split/client/api/SdkEvent.java new file mode 100644 index 00000000..e96983ba --- /dev/null +++ b/client/src/main/java/io/split/client/api/SdkEvent.java @@ -0,0 +1,26 @@ +package io.split.client.api; + +/** + * SDK lifecycle events a consumer can subscribe to. + * + *

{@code sdkReadyFromCache} is intentionally not part of this enum: it only applies to SDKs with a persistent cache. + */ +public enum SdkEvent { + /** + * The SDK has fetched feature flags and segments and is ready to evaluate. Fires once and is replayed + * (with {@code null} metadata) to listeners registered after it happened. + * Metadata: {@link SdkEventMetadata#isInitialCacheLoad()} and {@link SdkEventMetadata#getLastUpdateTimestamp()}. + */ + SDK_READY, + + /** + * The SDK did not become ready within the configured {@code blockUntilReady} time. Metadata is always {@code null}. + */ + SDK_READY_TIMED_OUT, + + /** + * Feature flags or segments changed after the SDK became ready. Fires every time, never replayed. + * Metadata: {@link SdkEventMetadata#getType()} and {@link SdkEventMetadata#getNames()}. + */ + SDK_UPDATE +} diff --git a/client/src/main/java/io/split/client/api/SdkEventListener.java b/client/src/main/java/io/split/client/api/SdkEventListener.java new file mode 100644 index 00000000..a56d64a5 --- /dev/null +++ b/client/src/main/java/io/split/client/api/SdkEventListener.java @@ -0,0 +1,13 @@ +package io.split.client.api; + +/** + * Callback invoked when a {@link SdkEvent} fires. + */ +@FunctionalInterface +public interface SdkEventListener { + /** + * @param metadata event details; may be {@code null} for {@link SdkEvent#SDK_READY_TIMED_OUT} + * and for a {@link SdkEvent#SDK_READY} replayed to a late subscriber + */ + void handle(SdkEventMetadata metadata); +} diff --git a/client/src/main/java/io/split/client/api/SdkEventMetadata.java b/client/src/main/java/io/split/client/api/SdkEventMetadata.java new file mode 100644 index 00000000..bcb82226 --- /dev/null +++ b/client/src/main/java/io/split/client/api/SdkEventMetadata.java @@ -0,0 +1,95 @@ +package io.split.client.api; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +/** + * Immutable details of a {@link SdkEvent}. + * + *

The metadata handed to a {@link SdkEventListener} can itself be {@code null}: always for + * {@link SdkEvent#SDK_READY_TIMED_OUT}, and for a once-only event replayed to a late subscriber. + */ +public final class SdkEventMetadata { + + private final SdkEventType type; + private final Set names; + private final boolean initialCacheLoad; + private final Long lastUpdateTimestamp; + + private SdkEventMetadata(SdkEventType type, Set names, boolean initialCacheLoad, Long lastUpdateTimestamp) { + this.type = type; + this.names = Collections.unmodifiableSet(names); + this.initialCacheLoad = initialCacheLoad; + this.lastUpdateTimestamp = lastUpdateTimestamp; + } + + /** + * Metadata for {@link SdkEvent#SDK_UPDATE}. Null and blank names are dropped. + */ + public static SdkEventMetadata update(SdkEventType type, Collection names) { + Set sanitized = new HashSet<>(); + if (names != null) { + for (String name : names) { + if (name != null && !name.trim().isEmpty()) { + sanitized.add(name); + } + } + } + return new SdkEventMetadata(type, sanitized, false, null); + } + + /** + * Metadata for {@link SdkEvent#SDK_READY}. + */ + public static SdkEventMetadata ready(boolean initialCacheLoad, Long lastUpdateTimestamp) { + return new SdkEventMetadata(null, new HashSet(), initialCacheLoad, lastUpdateTimestamp); + } + + /** @return the update type, or {@code null} for SDK_READY metadata */ + public SdkEventType getType() { + return type; + } + + /** @return the affected flag names; never null, unmodifiable */ + public Set getNames() { + return names; + } + + public boolean isInitialCacheLoad() { + return initialCacheLoad; + } + + /** @return milliseconds since epoch of the last update, or {@code null} if not applicable */ + public Long getLastUpdateTimestamp() { + return lastUpdateTimestamp; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SdkEventMetadata that = (SdkEventMetadata) o; + return initialCacheLoad == that.initialCacheLoad + && type == that.type + && names.equals(that.names) + && Objects.equals(lastUpdateTimestamp, that.lastUpdateTimestamp); + } + + @Override + public int hashCode() { + return Objects.hash(type, names, initialCacheLoad, lastUpdateTimestamp); + } + + @Override + public String toString() { + return "SdkEventMetadata{type=" + type + ", names=" + names + ", initialCacheLoad=" + initialCacheLoad + + ", lastUpdateTimestamp=" + lastUpdateTimestamp + "}"; + } +} diff --git a/client/src/main/java/io/split/client/api/SdkEventType.java b/client/src/main/java/io/split/client/api/SdkEventType.java new file mode 100644 index 00000000..104842f1 --- /dev/null +++ b/client/src/main/java/io/split/client/api/SdkEventType.java @@ -0,0 +1,12 @@ +package io.split.client.api; + +/** + * What kind of change triggered an {@link SdkEvent#SDK_UPDATE}. + */ +public enum SdkEventType { + /** One or more feature flags were added, updated, removed or killed. */ + FLAGS_UPDATE, + + /** Segments or rule-based segments changed. */ + SEGMENTS_UPDATE +} diff --git a/client/src/main/java/io/split/client/lifecycle/SdkEventsConfig.java b/client/src/main/java/io/split/client/lifecycle/SdkEventsConfig.java new file mode 100644 index 00000000..15bb6ee6 --- /dev/null +++ b/client/src/main/java/io/split/client/lifecycle/SdkEventsConfig.java @@ -0,0 +1,47 @@ +package io.split.client.lifecycle; + +import io.harness.events.EventsManagerConfig; +import io.split.client.api.SdkEvent; + +import java.util.Collections; + +/** + * Declarative rule graph wiring {@link SdkInternalEvent}s to the public {@link SdkEvent}s. + * + *

Package-private: this is the only place a raw commons {@code EventsManagerConfig} type is used, keeping it + * unreachable from outside {@code io.split.client.lifecycle}. + */ +final class SdkEventsConfig { + + private SdkEventsConfig() { + // utility class + } + + static EventsManagerConfig build() { + return EventsManagerConfig.builder() + .requireAll(SdkEvent.SDK_READY, SdkInternalEvent.SDK_READY) + .executionLimit(SdkEvent.SDK_READY, 1) + .metadataSource(SdkEvent.SDK_READY, SdkInternalEvent.SDK_READY) + + .requireAll(SdkEvent.SDK_READY_TIMED_OUT, SdkInternalEvent.SDK_READY_TIMEOUT_REACHED) + .executionLimit(SdkEvent.SDK_READY_TIMED_OUT, 1) + .metadataSource(SdkEvent.SDK_READY_TIMED_OUT, SdkInternalEvent.SDK_READY_TIMEOUT_REACHED) + + .requireAny(SdkEvent.SDK_UPDATE, + SdkInternalEvent.FLAGS_UPDATED, + SdkInternalEvent.FLAG_KILLED_NOTIFICATION, + SdkInternalEvent.SEGMENTS_UPDATED, + SdkInternalEvent.RULE_BASED_SEGMENTS_UPDATED) + .prerequisite(SdkEvent.SDK_UPDATE, SdkEvent.SDK_READY) + .executionLimit(SdkEvent.SDK_UPDATE, -1) + .metadataSource(SdkEvent.SDK_UPDATE, + Collections.singleton(SdkInternalEvent.FLAGS_UPDATED), SdkInternalEvent.FLAGS_UPDATED) + .metadataSource(SdkEvent.SDK_UPDATE, + Collections.singleton(SdkInternalEvent.FLAG_KILLED_NOTIFICATION), SdkInternalEvent.FLAG_KILLED_NOTIFICATION) + .metadataSource(SdkEvent.SDK_UPDATE, + Collections.singleton(SdkInternalEvent.SEGMENTS_UPDATED), SdkInternalEvent.SEGMENTS_UPDATED) + .metadataSource(SdkEvent.SDK_UPDATE, + Collections.singleton(SdkInternalEvent.RULE_BASED_SEGMENTS_UPDATED), SdkInternalEvent.RULE_BASED_SEGMENTS_UPDATED) + .build(); + } +} diff --git a/client/src/main/java/io/split/client/lifecycle/SdkEventsManager.java b/client/src/main/java/io/split/client/lifecycle/SdkEventsManager.java new file mode 100644 index 00000000..83ad194e --- /dev/null +++ b/client/src/main/java/io/split/client/lifecycle/SdkEventsManager.java @@ -0,0 +1,198 @@ +package io.split.client.lifecycle; + +import io.harness.events.EventHandler; +import io.harness.events.EventsManager; +import io.harness.events.EventsManagers; +import io.split.client.api.SdkEvent; +import io.split.client.api.SdkEventListener; +import io.split.client.api.SdkEventMetadata; +import io.split.client.utils.SplitExecutorFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadFactory; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Wraps the commons {@code io.harness.events} library to expose {@link SdkEvent} registration/notification in terms + * of {@code Sdk*} types only. No {@code io.harness.events} type appears in any public method of this class. + * + *

Registers exactly one dispatcher per {@link SdkEvent} with the underlying commons manager, because commons + * stores multiple handlers for the same event in a {@code HashSet} (unordered) and delivers with no per-handler + * error isolation. This class owns its own per-event ordered listener list and its own try/catch per listener, + * so registration-order delivery and error isolation are guaranteed regardless of commons' internals. + * + *

Delivery runs on a dedicated single-thread executor, never on the caller's thread and never on commons' + * own internal processing thread: commons calls our {@code EventDelivery} synchronously from the caller's thread + * when replaying to a late subscriber, and serializes all internal-event processing for every event on one + * background thread. Handing delivery off to our own executor keeps a slow listener from blocking either. + * + *

This layer is purely additive to readiness. {@code SDKReadinessGates} stays the authoritative source for + * {@code isSDKReady()}, deliberately not {@link #eventAlreadyTriggered(SdkEvent)}: the commons implementation waits on + * its internal process queue with no timeout, which must never happen on the evaluation path. + */ +public final class SdkEventsManager { + + private static final Logger LOG = LoggerFactory.getLogger(SdkEventsManager.class); + + private final EventsManager commonsManager; + private final ExecutorService deliveryExecutor; + private final Object registrationLock = new Object(); + private final Map> listeners = new HashMap<>(); + private final Set delivered = new HashSet<>(); + private boolean destroyed = false; + + public static SdkEventsManager create(ThreadFactory threadFactory) { + return new SdkEventsManager(threadFactory); + } + + SdkEventsManager(ThreadFactory threadFactory) { + for (SdkEvent event : SdkEvent.values()) { + listeners.put(event, new ArrayList()); + } + deliveryExecutor = SplitExecutorFactory.buildExecutorService(threadFactory, "SPLIT-EventsManager-%d"); + commonsManager = EventsManagers.create(SdkEventsConfig.build(), this::deliverAsync); + for (SdkEvent event : SdkEvent.values()) { + commonsManager.register(event, this::dispatch); + } + } + + /** + * Registers a listener for {@code event}. If the event is once-only (SDK_READY, SDK_READY_TIMED_OUT) and has + * already fired, the listener is delivered {@code null} metadata asynchronously instead of being registered + * for future delivery (there won't be one). No-op after {@link #destroy()}. + */ + public void on(SdkEvent event, SdkEventListener listener) { + checkNotNull(event); + checkNotNull(listener); + boolean replay; + synchronized (registrationLock) { + if (destroyed) { + return; + } + if (delivered.contains(event)) { + replay = true; + } else { + listeners.get(event).add(listener); + replay = false; + } + } + if (replay) { + submitQuietly(() -> safeInvoke(listener, event, null)); + } + } + + /** + * Removes all listeners registered for {@code event}. Does not affect whether the event can fire again in + * the future, or whether new listeners can be registered afterward. + */ + public void off(SdkEvent event) { + checkNotNull(event); + synchronized (registrationLock) { + if (destroyed) { + return; + } + listeners.get(event).clear(); + } + // Deliberately not calling the commons manager's unregister(event): that would remove our permanent + // per-event dispatcher, and this event could never fire again for the lifetime of this manager. + } + + public void notifyInternalEvent(SdkInternalEvent event, SdkEventMetadata metadata) { + if (isDestroyed()) { + return; + } + LOG.debug("Internal event {} notified, metadata: {}", event, metadata); + commonsManager.notifyInternalEvent(event, metadata); + } + + public boolean eventAlreadyTriggered(SdkEvent event) { + if (isDestroyed()) { + return false; + } + return commonsManager.eventAlreadyTriggered(event); + } + + /** + * Stops this manager: no further events fire, {@link #on(SdkEvent, SdkEventListener)} becomes a no-op, and all + * registered listeners are dropped. + */ + public void destroy() { + synchronized (registrationLock) { + if (destroyed) { + return; + } + destroyed = true; + listeners.clear(); + } + commonsManager.destroy(); + deliveryExecutor.shutdown(); + } + + private boolean isDestroyed() { + synchronized (registrationLock) { + return destroyed; + } + } + + private void deliverAsync(EventHandler handler, SdkEvent event, SdkEventMetadata metadata) { + submitQuietly(() -> { + try { + handler.handle(event, metadata); + } catch (Throwable t) { + LOG.warn("Unexpected error delivering " + event, t); + } + }); + } + + // destroy() can shut the executor down while a delivery is in flight; dropping it is the intended outcome. + private void submitQuietly(Runnable task) { + try { + deliveryExecutor.submit(task); + } catch (RejectedExecutionException e) { + LOG.debug("Delivery dropped: events manager was destroyed"); + } + } + + private void dispatch(SdkEvent event, SdkEventMetadata metadata) { + List snapshot; + synchronized (registrationLock) { + if (destroyed) { + return; + } + // Readiness won the race: a timeout that was notified after SDK_READY was dispatched is dropped. + if (event == SdkEvent.SDK_READY_TIMED_OUT && delivered.contains(SdkEvent.SDK_READY)) { + return; + } + snapshot = new ArrayList<>(listeners.get(event)); + if (isOnceOnly(event)) { + delivered.add(event); + } + } + LOG.debug("Emitting {} to {} listener(s), metadata: {}", event, snapshot.size(), metadata); + for (SdkEventListener listener : snapshot) { + safeInvoke(listener, event, metadata); + } + } + + private void safeInvoke(SdkEventListener listener, SdkEvent event, SdkEventMetadata metadata) { + try { + listener.handle(metadata); + } catch (Throwable t) { + LOG.warn("SdkEventListener threw an exception handling " + event, t); + } + } + + private static boolean isOnceOnly(SdkEvent event) { + return event == SdkEvent.SDK_READY || event == SdkEvent.SDK_READY_TIMED_OUT; + } +} diff --git a/client/src/main/java/io/split/client/lifecycle/SdkEventsNotifier.java b/client/src/main/java/io/split/client/lifecycle/SdkEventsNotifier.java new file mode 100644 index 00000000..5c57ef5f --- /dev/null +++ b/client/src/main/java/io/split/client/lifecycle/SdkEventsNotifier.java @@ -0,0 +1,13 @@ +package io.split.client.lifecycle; + +import io.split.client.api.SdkEventMetadata; + +/** + * Write-path hook used by the sync engine to report that cached data changed. Implementations must not throw. + */ +public interface SdkEventsNotifier { + + SdkEventsNotifier NOOP = (event, metadata) -> { }; + + void notify(SdkInternalEvent event, SdkEventMetadata metadata); +} diff --git a/client/src/main/java/io/split/client/lifecycle/SdkInternalEvent.java b/client/src/main/java/io/split/client/lifecycle/SdkInternalEvent.java new file mode 100644 index 00000000..d9fe7214 --- /dev/null +++ b/client/src/main/java/io/split/client/lifecycle/SdkInternalEvent.java @@ -0,0 +1,14 @@ +package io.split.client.lifecycle; + +/** + * Internal signals fed into the events manager (the {@code I} type parameter of the commons + * {@code EventsManager}). Not part of the supported public API. + */ +public enum SdkInternalEvent { + SDK_READY, + FLAGS_UPDATED, + FLAG_KILLED_NOTIFICATION, + SEGMENTS_UPDATED, + RULE_BASED_SEGMENTS_UPDATED, + SDK_READY_TIMEOUT_REACHED +} diff --git a/client/src/main/java/io/split/engine/SDKReadinessGates.java b/client/src/main/java/io/split/engine/SDKReadinessGates.java index c5ca5dbc..bb909c04 100644 --- a/client/src/main/java/io/split/engine/SDKReadinessGates.java +++ b/client/src/main/java/io/split/engine/SDKReadinessGates.java @@ -8,6 +8,12 @@ /** * Created by adilaijaz on 4/27/16. + * + *

Authoritative source for {@code isSDKReady()} and {@code blockUntilReady()}. + * + *

The SDK events layer ({@code SdkEventsManager}) is additive and is notified alongside {@link #sdkInternalReady()} + * by its callers; it is deliberately not consulted here. {@code eventAlreadyTriggered} in the commons library blocks + * on an internal queue with no timeout, which would add a cross-thread wait to the evaluation path. */ public class SDKReadinessGates { private static final Logger _log = LoggerFactory.getLogger(SDKReadinessGates.class); diff --git a/client/src/main/java/io/split/engine/common/LocalhostSyncManager.java b/client/src/main/java/io/split/engine/common/LocalhostSyncManager.java index bb7bc7c4..1ec75711 100644 --- a/client/src/main/java/io/split/engine/common/LocalhostSyncManager.java +++ b/client/src/main/java/io/split/engine/common/LocalhostSyncManager.java @@ -1,5 +1,8 @@ package io.split.engine.common; +import io.split.client.api.SdkEventMetadata; +import io.split.client.lifecycle.SdkEventsNotifier; +import io.split.client.lifecycle.SdkInternalEvent; import io.split.engine.SDKReadinessGates; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -14,9 +17,17 @@ public class LocalhostSyncManager implements SyncManager { private final Synchronizer _localhostSynchronizer; private final SDKReadinessGates _gates; + private final SdkEventsNotifier _notifier; + public LocalhostSyncManager(Synchronizer localhostSynchronizer, SDKReadinessGates sdkReadinessGates){ + this(localhostSynchronizer, sdkReadinessGates, SdkEventsNotifier.NOOP); + } + + public LocalhostSyncManager(Synchronizer localhostSynchronizer, SDKReadinessGates sdkReadinessGates, + SdkEventsNotifier notifier) { _localhostSynchronizer = checkNotNull(localhostSynchronizer); _gates = sdkReadinessGates; + _notifier = checkNotNull(notifier); } @Override @@ -26,6 +37,7 @@ public void start() { return; } _gates.sdkInternalReady(); + _notifier.notify(SdkInternalEvent.SDK_READY, SdkEventMetadata.ready(true, null)); _localhostSynchronizer.startPeriodicFetching(); } diff --git a/client/src/main/java/io/split/engine/common/PushManagerImp.java b/client/src/main/java/io/split/engine/common/PushManagerImp.java index d7b5d8ae..12e061da 100644 --- a/client/src/main/java/io/split/engine/common/PushManagerImp.java +++ b/client/src/main/java/io/split/engine/common/PushManagerImp.java @@ -2,6 +2,7 @@ import com.google.common.annotations.VisibleForTesting; import io.split.client.interceptors.FlagSetsFilter; +import io.split.client.lifecycle.SdkEventsNotifier; import io.split.engine.experiments.RuleBasedSegmentParser; import io.split.engine.experiments.SplitParser; import io.split.engine.sse.AuthApiClient; @@ -84,8 +85,26 @@ public static PushManagerImp build(Synchronizer synchronizer, FlagSetsFilter flagSetsFilter, RuleBasedSegmentCache ruleBasedSegmentCache, RuleBasedSegmentParser ruleBasedSegmentParser) { + return build(synchronizer, streamingUrl, authUrl, splitAPI, statusMessages, telemetryRuntimeProducer, threadFactory, + splitParser, splitCacheProducer, flagSetsFilter, ruleBasedSegmentCache, ruleBasedSegmentParser, + SdkEventsNotifier.NOOP); + } + + public static PushManagerImp build(Synchronizer synchronizer, + String streamingUrl, + String authUrl, + SplitAPI splitAPI, + LinkedBlockingQueue statusMessages, + TelemetryRuntimeProducer telemetryRuntimeProducer, + ThreadFactory threadFactory, + SplitParser splitParser, + SplitCacheProducer splitCacheProducer, + FlagSetsFilter flagSetsFilter, + RuleBasedSegmentCache ruleBasedSegmentCache, + RuleBasedSegmentParser ruleBasedSegmentParser, + SdkEventsNotifier notifier) { FeatureFlagsWorker featureFlagsWorker = new FeatureFlagWorkerImp(synchronizer, splitParser, ruleBasedSegmentParser, splitCacheProducer, - ruleBasedSegmentCache, telemetryRuntimeProducer, flagSetsFilter); + ruleBasedSegmentCache, telemetryRuntimeProducer, flagSetsFilter, notifier); Worker segmentWorker = new SegmentsWorkerImp(synchronizer); PushStatusTracker pushStatusTracker = new PushStatusTrackerImp(statusMessages, telemetryRuntimeProducer); diff --git a/client/src/main/java/io/split/engine/common/SyncManagerImp.java b/client/src/main/java/io/split/engine/common/SyncManagerImp.java index 57faa07b..f8bef316 100644 --- a/client/src/main/java/io/split/engine/common/SyncManagerImp.java +++ b/client/src/main/java/io/split/engine/common/SyncManagerImp.java @@ -3,6 +3,9 @@ import com.google.common.annotations.VisibleForTesting; import io.split.client.ApiKeyCounter; import io.split.client.SplitClientConfig; +import io.split.client.api.SdkEventMetadata; +import io.split.client.lifecycle.SdkEventsNotifier; +import io.split.client.lifecycle.SdkInternalEvent; import io.split.client.interceptors.FlagSetsFilter; import io.split.engine.SDKReadinessGates; import io.split.engine.experiments.RuleBasedSegmentParser; @@ -51,6 +54,7 @@ public class SyncManagerImp implements SyncManager { private final SplitSynchronizationTask _splitSynchronizationTask; private static final long STARTING_SYNC_ALL_BACKOFF_MAX_WAIT_MS = 10000; // 10 seconds max wait private final SplitAPI _splitAPI; + private final SdkEventsNotifier _notifier; @VisibleForTesting /* package private */ SyncManagerImp(SplitTasks splitTasks, @@ -63,6 +67,22 @@ public class SyncManagerImp implements SyncManager { TelemetrySynchronizer telemetrySynchronizer, SplitClientConfig config, SplitAPI splitAPI) { + this(splitTasks, streamingEnabledConfig, synchronizer, pushManager, pushMessages, gates, telemetryRuntimeProducer, + telemetrySynchronizer, config, splitAPI, SdkEventsNotifier.NOOP); + } + + /* package private */ SyncManagerImp(SplitTasks splitTasks, + boolean streamingEnabledConfig, + Synchronizer synchronizer, + PushManager pushManager, + LinkedBlockingQueue pushMessages, + SDKReadinessGates gates, + TelemetryRuntimeProducer telemetryRuntimeProducer, + TelemetrySynchronizer telemetrySynchronizer, + SplitClientConfig config, + SplitAPI splitAPI, + SdkEventsNotifier notifier) { + _notifier = checkNotNull(notifier); _streamingEnabledConfig = new AtomicBoolean(streamingEnabledConfig); _synchronizer = checkNotNull(synchronizer); _pushManager = checkNotNull(pushManager); @@ -93,7 +113,8 @@ public static SyncManagerImp build(SplitTasks splitTasks, SplitParser splitParser, RuleBasedSegmentParser ruleBasedSegmentParser, FlagSetsFilter flagSetsFilter, - RuleBasedSegmentCache ruleBasedSegmentCache) { + RuleBasedSegmentCache ruleBasedSegmentCache, + SdkEventsNotifier notifier) { LinkedBlockingQueue pushMessages = new LinkedBlockingQueue<>(); Synchronizer synchronizer = new SynchronizerImp(splitTasks, splitFetcher, @@ -103,7 +124,8 @@ public static SyncManagerImp build(SplitTasks splitTasks, config.streamingRetryDelay(), config.streamingFetchMaxRetries(), config.failedAttemptsBeforeLogging(), - config.getSetsFilter()); + config.getSetsFilter(), + notifier); PushManager pushManager = PushManagerImp.build(synchronizer, config.streamingServiceURL(), @@ -116,7 +138,8 @@ public static SyncManagerImp build(SplitTasks splitTasks, splitCacheProducer, flagSetsFilter, ruleBasedSegmentCache, - ruleBasedSegmentParser); + ruleBasedSegmentParser, + notifier); return new SyncManagerImp(splitTasks, config.streamingEnabled(), @@ -127,7 +150,8 @@ public static SyncManagerImp build(SplitTasks splitTasks, telemetryRuntimeProducer, telemetrySynchronizer, config, - splitAPI); + splitAPI, + notifier); } @Override @@ -150,6 +174,7 @@ public void start() { _log.debug("SyncAll Ready"); } _gates.sdkInternalReady(); + _notifier.notify(SdkInternalEvent.SDK_READY, SdkEventMetadata.ready(true, null)); if (_streamingEnabledConfig.get()) { startStreamingMode(); } else { diff --git a/client/src/main/java/io/split/engine/common/SynchronizerImp.java b/client/src/main/java/io/split/engine/common/SynchronizerImp.java index d9210578..9679354a 100644 --- a/client/src/main/java/io/split/engine/common/SynchronizerImp.java +++ b/client/src/main/java/io/split/engine/common/SynchronizerImp.java @@ -1,6 +1,10 @@ package io.split.engine.common; +import io.split.client.api.SdkEventMetadata; +import io.split.client.api.SdkEventType; import io.split.client.events.EventsTask; +import io.split.client.lifecycle.SdkEventsNotifier; +import io.split.client.lifecycle.SdkInternalEvent; import io.split.client.impressions.ImpressionsManager; import io.split.client.impressions.UniqueKeysTracker; import io.split.engine.experiments.FetchResult; @@ -16,6 +20,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Collections; import java.util.HashSet; import java.util.function.Function; import java.util.stream.Collectors; @@ -45,6 +50,7 @@ public class SynchronizerImp implements Synchronizer { private final int _onDemandFetchMaxRetries; private final int _failedAttemptsBeforeLogging; private final String _sets; + private final SdkEventsNotifier _notifier; public SynchronizerImp(SplitTasks splitTasks, SplitFetcher splitFetcher, @@ -55,6 +61,21 @@ public SynchronizerImp(SplitTasks splitTasks, int onDemandFetchMaxRetries, int failedAttemptsBeforeLogging, HashSet sets) { + this(splitTasks, splitFetcher, splitCacheProducer, segmentCacheProducer, ruleBasedSegmentCacheProducer, + onDemandFetchRetryDelayMs, onDemandFetchMaxRetries, failedAttemptsBeforeLogging, sets, SdkEventsNotifier.NOOP); + } + + public SynchronizerImp(SplitTasks splitTasks, + SplitFetcher splitFetcher, + SplitCacheProducer splitCacheProducer, + SegmentCacheProducer segmentCacheProducer, + RuleBasedSegmentCacheProducer ruleBasedSegmentCacheProducer, + int onDemandFetchRetryDelayMs, + int onDemandFetchMaxRetries, + int failedAttemptsBeforeLogging, + HashSet sets, + SdkEventsNotifier notifier) { + _notifier = checkNotNull(notifier); _splitSynchronizationTask = checkNotNull(splitTasks.getSplitSynchronizationTask()); _splitFetcher = checkNotNull(splitFetcher); _segmentSynchronizationTaskImp = checkNotNull(splitTasks.getSegmentSynchronizationTask()); @@ -188,6 +209,8 @@ public void localKillSplit(SplitKillNotification splitKillNotification) { if (splitKillNotification.getChangeNumber() > _splitCacheProducer.getChangeNumber()) { _splitCacheProducer.kill(splitKillNotification.getSplitName(), splitKillNotification.getDefaultTreatment(), splitKillNotification.getChangeNumber()); + _notifier.notify(SdkInternalEvent.FLAG_KILLED_NOTIFICATION, + SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, Collections.singleton(splitKillNotification.getSplitName()))); refreshSplits(splitKillNotification.getChangeNumber(), 0L); } } diff --git a/client/src/main/java/io/split/engine/experiments/SplitFetcherImp.java b/client/src/main/java/io/split/engine/experiments/SplitFetcherImp.java index 30d374cd..15ad9e40 100644 --- a/client/src/main/java/io/split/engine/experiments/SplitFetcherImp.java +++ b/client/src/main/java/io/split/engine/experiments/SplitFetcherImp.java @@ -13,6 +13,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import io.split.client.api.SdkEventMetadata; +import io.split.client.api.SdkEventType; +import io.split.client.lifecycle.SdkEventsNotifier; +import io.split.client.lifecycle.SdkInternalEvent; + +import java.util.Collections; import java.util.HashSet; import java.util.Set; @@ -38,6 +44,7 @@ public class SplitFetcherImp implements SplitFetcher { private final FlagSetsFilter _flagSetsFilter; private final RuleBasedSegmentCacheProducer _ruleBasedSegmentCacheProducer; private final RuleBasedSegmentParser _parserRBS; + private final SdkEventsNotifier _notifier; /** * Contains all the traffic types that are currently being used by the splits and also the count @@ -52,6 +59,15 @@ public class SplitFetcherImp implements SplitFetcher { public SplitFetcherImp(SplitChangeFetcher splitChangeFetcher, SplitParser parser, SplitCacheProducer splitCacheProducer, TelemetryRuntimeProducer telemetryRuntimeProducer, FlagSetsFilter flagSetsFilter, RuleBasedSegmentParser parserRBS, RuleBasedSegmentCacheProducer ruleBasedSegmentCacheProducer) { + this(splitChangeFetcher, parser, splitCacheProducer, telemetryRuntimeProducer, flagSetsFilter, parserRBS, + ruleBasedSegmentCacheProducer, SdkEventsNotifier.NOOP); + } + + public SplitFetcherImp(SplitChangeFetcher splitChangeFetcher, SplitParser parser, SplitCacheProducer splitCacheProducer, + TelemetryRuntimeProducer telemetryRuntimeProducer, FlagSetsFilter flagSetsFilter, + RuleBasedSegmentParser parserRBS, RuleBasedSegmentCacheProducer ruleBasedSegmentCacheProducer, + SdkEventsNotifier notifier) { + _notifier = checkNotNull(notifier); _splitChangeFetcher = checkNotNull(splitChangeFetcher); _parser = checkNotNull(parser); _parserRBS = checkNotNull(parserRBS); @@ -162,6 +178,17 @@ private Set runWithoutExceptionHandling(FetchOptions options) throws Int _ruleBasedSegmentCacheProducer.update(ruleBasedSegmentsToUpdate.getToAdd(), ruleBasedSegmentsToUpdate.getToRemove(), change.ruleBasedSegments.t); _telemetryRuntimeProducer.recordSuccessfulSync(LastSynchronizationRecordsEnum.SPLITS, System.currentTimeMillis()); + + Set flagNames = new HashSet<>(featureFlagsToUpdate.getToRemove()); + featureFlagsToUpdate.getToAdd().forEach(p -> flagNames.add(p.feature())); + boolean rbsChanged = !ruleBasedSegmentsToUpdate.getToAdd().isEmpty() + || !ruleBasedSegmentsToUpdate.getToRemove().isEmpty(); + if (!flagNames.isEmpty()) { + _notifier.notify(SdkInternalEvent.FLAGS_UPDATED, SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, flagNames)); + } else if (rbsChanged) { + _notifier.notify(SdkInternalEvent.RULE_BASED_SEGMENTS_UPDATED, + SdkEventMetadata.update(SdkEventType.SEGMENTS_UPDATE, Collections.emptySet())); + } } return segments; diff --git a/client/src/main/java/io/split/engine/segments/SegmentFetcherImp.java b/client/src/main/java/io/split/engine/segments/SegmentFetcherImp.java index 526ca02d..f0df3bdd 100644 --- a/client/src/main/java/io/split/engine/segments/SegmentFetcherImp.java +++ b/client/src/main/java/io/split/engine/segments/SegmentFetcherImp.java @@ -8,7 +8,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import io.split.client.api.SdkEventMetadata; +import io.split.client.api.SdkEventType; +import io.split.client.lifecycle.SdkEventsNotifier; +import io.split.client.lifecycle.SdkInternalEvent; + import java.util.ArrayList; +import java.util.Collections; import java.util.List; import static com.google.common.base.Preconditions.checkNotNull; @@ -21,10 +27,18 @@ public class SegmentFetcherImp implements SegmentFetcher { private final SegmentCacheProducer _segmentCacheProducer; private final TelemetryRuntimeProducer _telemetryRuntimeProducer; + private final SdkEventsNotifier _notifier; + private final Object _lock = new Object(); public SegmentFetcherImp(String segmentName, SegmentChangeFetcher segmentChangeFetcher, SegmentCacheProducer segmentCacheProducer, TelemetryRuntimeProducer telemetryRuntimeProducer) { + this(segmentName, segmentChangeFetcher, segmentCacheProducer, telemetryRuntimeProducer, SdkEventsNotifier.NOOP); + } + + public SegmentFetcherImp(String segmentName, SegmentChangeFetcher segmentChangeFetcher, SegmentCacheProducer segmentCacheProducer, + TelemetryRuntimeProducer telemetryRuntimeProducer, SdkEventsNotifier notifier) { + _notifier = checkNotNull(notifier); _segmentName = checkNotNull(segmentName); _segmentChangeFetcher = checkNotNull(segmentChangeFetcher); _segmentCacheProducer = checkNotNull(segmentCacheProducer); @@ -98,6 +112,9 @@ private void runWithoutExceptionHandling(FetchOptions options) { } _telemetryRuntimeProducer.recordSuccessfulSync(LastSynchronizationRecordsEnum.SEGMENTS, System.currentTimeMillis()); + + _notifier.notify(SdkInternalEvent.SEGMENTS_UPDATED, + SdkEventMetadata.update(SdkEventType.SEGMENTS_UPDATE, Collections.emptySet())); } } diff --git a/client/src/main/java/io/split/engine/segments/SegmentSynchronizationTaskImp.java b/client/src/main/java/io/split/engine/segments/SegmentSynchronizationTaskImp.java index 85749308..095e619b 100644 --- a/client/src/main/java/io/split/engine/segments/SegmentSynchronizationTaskImp.java +++ b/client/src/main/java/io/split/engine/segments/SegmentSynchronizationTaskImp.java @@ -1,6 +1,7 @@ package io.split.engine.segments; import com.google.common.collect.Maps; +import io.split.client.lifecycle.SdkEventsNotifier; import io.split.client.utils.SplitExecutorFactory; import io.split.engine.common.FetchOptions; import io.split.storages.RuleBasedSegmentCacheConsumer; @@ -43,12 +44,23 @@ public class SegmentSynchronizationTaskImp implements SegmentSynchronizationTask private final SplitCacheConsumer _splitCacheConsumer; private final RuleBasedSegmentCacheConsumer _ruleBasedSegmentCacheConsumer; + private final SdkEventsNotifier _notifier; + private ScheduledFuture _scheduledFuture; public SegmentSynchronizationTaskImp(SegmentChangeFetcher segmentChangeFetcher, long refreshEveryNSeconds, int numThreads, SegmentCacheProducer segmentCacheProducer, TelemetryRuntimeProducer telemetryRuntimeProducer, SplitCacheConsumer splitCacheConsumer, ThreadFactory threadFactory, RuleBasedSegmentCacheConsumer ruleBasedSegmentCacheConsumer) { + this(segmentChangeFetcher, refreshEveryNSeconds, numThreads, segmentCacheProducer, telemetryRuntimeProducer, + splitCacheConsumer, threadFactory, ruleBasedSegmentCacheConsumer, SdkEventsNotifier.NOOP); + } + + public SegmentSynchronizationTaskImp(SegmentChangeFetcher segmentChangeFetcher, long refreshEveryNSeconds, int numThreads, + SegmentCacheProducer segmentCacheProducer, TelemetryRuntimeProducer telemetryRuntimeProducer, + SplitCacheConsumer splitCacheConsumer, ThreadFactory threadFactory, + RuleBasedSegmentCacheConsumer ruleBasedSegmentCacheConsumer, SdkEventsNotifier notifier) { + _notifier = checkNotNull(notifier); _segmentChangeFetcher = checkNotNull(segmentChangeFetcher); checkArgument(refreshEveryNSeconds >= 0L); @@ -77,7 +89,8 @@ public void initializeSegment(String segmentName) { return; } - SegmentFetcher newSegment = new SegmentFetcherImp(segmentName, _segmentChangeFetcher, _segmentCacheProducer, _telemetryRuntimeProducer); + SegmentFetcher newSegment = new SegmentFetcherImp(segmentName, _segmentChangeFetcher, _segmentCacheProducer, + _telemetryRuntimeProducer, _notifier); if (_running.get()) { _scheduledExecutorService.submit(() -> newSegment.fetch(new FetchOptions.Builder().build())); @@ -195,7 +208,7 @@ private void initialize(String segmentName) { return; } - segment = new SegmentFetcherImp(segmentName, _segmentChangeFetcher, _segmentCacheProducer, _telemetryRuntimeProducer); + segment = new SegmentFetcherImp(segmentName, _segmentChangeFetcher, _segmentCacheProducer, _telemetryRuntimeProducer, _notifier); _segmentFetchers.putIfAbsent(segmentName, segment); } diff --git a/client/src/main/java/io/split/engine/sse/workers/FeatureFlagWorkerImp.java b/client/src/main/java/io/split/engine/sse/workers/FeatureFlagWorkerImp.java index d15d2a43..6001bb22 100644 --- a/client/src/main/java/io/split/engine/sse/workers/FeatureFlagWorkerImp.java +++ b/client/src/main/java/io/split/engine/sse/workers/FeatureFlagWorkerImp.java @@ -3,6 +3,10 @@ import io.split.client.dtos.RuleBasedSegment; import io.split.client.dtos.Split; import io.split.client.interceptors.FlagSetsFilter; +import io.split.client.lifecycle.SdkEventsNotifier; +import io.split.client.lifecycle.SdkInternalEvent; +import io.split.client.api.SdkEventMetadata; +import io.split.client.api.SdkEventType; import io.split.client.utils.FeatureFlagsToUpdate; import io.split.client.utils.RuleBasedSegmentsToUpdate; import io.split.engine.common.Synchronizer; @@ -19,6 +23,7 @@ import org.slf4j.LoggerFactory; import java.util.Collections; +import java.util.HashSet; import java.util.Set; import static com.google.common.base.Preconditions.checkNotNull; @@ -34,12 +39,23 @@ public class FeatureFlagWorkerImp extends Worker implement private final RuleBasedSegmentCache _ruleBasedSegmentCache; private final TelemetryRuntimeProducer _telemetryRuntimeProducer; private final FlagSetsFilter _flagSetsFilter; + private final SdkEventsNotifier _notifier; public FeatureFlagWorkerImp(Synchronizer synchronizer, SplitParser splitParser, RuleBasedSegmentParser ruleBasedSegmentParser, SplitCacheProducer splitCacheProducer, RuleBasedSegmentCache ruleBasedSegmentCache, TelemetryRuntimeProducer telemetryRuntimeProducer, FlagSetsFilter flagSetsFilter) { + this(synchronizer, splitParser, ruleBasedSegmentParser, splitCacheProducer, ruleBasedSegmentCache, + telemetryRuntimeProducer, flagSetsFilter, SdkEventsNotifier.NOOP); + } + + public FeatureFlagWorkerImp(Synchronizer synchronizer, SplitParser splitParser, RuleBasedSegmentParser ruleBasedSegmentParser, + SplitCacheProducer splitCacheProducer, + RuleBasedSegmentCache ruleBasedSegmentCache, + TelemetryRuntimeProducer telemetryRuntimeProducer, FlagSetsFilter flagSetsFilter, + SdkEventsNotifier notifier) { super("Feature flags"); + _notifier = checkNotNull(notifier); _synchronizer = checkNotNull(synchronizer); _splitParser = splitParser; _ruleBasedSegmentParser = ruleBasedSegmentParser; @@ -90,6 +106,10 @@ private boolean addOrUpdateRuleBasedSegment(CommonChangeNotification ruleBasedSe Collections.singletonList(ruleBasedSegment)); _ruleBasedSegmentCache.update(ruleBasedSegmentsToUpdate.getToAdd(), ruleBasedSegmentsToUpdate.getToRemove(), ruleBasedSegmentChangeNotification.getChangeNumber()); + if (!ruleBasedSegmentsToUpdate.getToAdd().isEmpty() || !ruleBasedSegmentsToUpdate.getToRemove().isEmpty()) { + _notifier.notify(SdkInternalEvent.RULE_BASED_SEGMENTS_UPDATED, + SdkEventMetadata.update(SdkEventType.SEGMENTS_UPDATE, Collections.emptySet())); + } Set segments = ruleBasedSegmentsToUpdate.getSegments(); for (String segmentName: segments) { _synchronizer.forceRefreshSegment(segmentName); @@ -115,6 +135,11 @@ private boolean addOrUpdateFeatureFlag(CommonChangeNotification featureFlagChang _flagSetsFilter); _splitCacheProducer.update(featureFlagsToUpdate.getToAdd(), featureFlagsToUpdate.getToRemove(), featureFlagChangeNotification.getChangeNumber()); + Set flagNames = new HashSet<>(featureFlagsToUpdate.getToRemove()); + featureFlagsToUpdate.getToAdd().forEach(f -> flagNames.add(f.feature())); + if (!flagNames.isEmpty()) { + _notifier.notify(SdkInternalEvent.FLAGS_UPDATED, SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, flagNames)); + } Set segments = featureFlagsToUpdate.getSegments(); for (String segmentName: segments) { _synchronizer.forceRefreshSegment(segmentName); diff --git a/client/src/test/java/io/split/client/SdkLifecycleEventsModesIntegrationTest.java b/client/src/test/java/io/split/client/SdkLifecycleEventsModesIntegrationTest.java new file mode 100644 index 00000000..498b75ae --- /dev/null +++ b/client/src/test/java/io/split/client/SdkLifecycleEventsModesIntegrationTest.java @@ -0,0 +1,114 @@ +package io.split.client; + +import io.split.client.lifecycle.EventThreads; +import io.split.client.lifecycle.SdkLifecycleEventsIntegrationTest; +import io.split.client.api.SdkEvent; +import io.split.storages.enums.OperationMode; +import org.junit.After; +import org.junit.Test; +import pluggable.CustomStorageWrapper; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Lifecycle events in the modes that have no sync engine producing updates: localhost and consumer. Both emit + * SDK_READY (with the metadata of their mode) and neither ever emits SDK_UPDATE. + */ +public class SdkLifecycleEventsModesIntegrationTest { + + private static final long WAIT_SECONDS = 20L; + private static final Duration QUIET_WINDOW = Duration.ofMillis(1500); + + private SplitFactoryImpl factory; + + @After + public void destroyFactory() { + if (factory != null) { + factory.destroy(); + } + EventThreads.awaitRecordedThreadsTerminated(); + } + + // Scenario: sdkReady fires once and replays to late subscribers (localhost mode). + @Test + public void localhostFiresSdkReadyOnceReplaysAndNeverRaisesAnUpdate() throws Exception { + SplitClientConfig config = SplitClientConfig.builder() + .splitFile("src/test/resources/splits_localhost.json") + .setBlockUntilReadyTimeout(10000) + .build(); + factory = new SplitFactoryImpl(config); + SplitClient client = factory.client(); + SdkLifecycleEventsIntegrationTest.Recorder ready = new SdkLifecycleEventsIntegrationTest.Recorder(); + SdkLifecycleEventsIntegrationTest.Recorder updates = new SdkLifecycleEventsIntegrationTest.Recorder(); + client.on(SdkEvent.SDK_READY, ready); + client.on(SdkEvent.SDK_UPDATE, updates); + + client.blockUntilReady(); + // localhost loads its file while the factory is constructed, so this subscriber may be a replay (no metadata) + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> ready.size() == 1); + + SdkLifecycleEventsIntegrationTest.Recorder late = new SdkLifecycleEventsIntegrationTest.Recorder(); + client.on(SdkEvent.SDK_READY, late); + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> late.size() == 1); + assertNull(late.get(0)); + + await().during(QUIET_WINDOW).atMost(WAIT_SECONDS, TimeUnit.SECONDS) + .until(() -> ready.size() == 1 && updates.size() == 0); + } + + // Scenario: destroy stops events and clears handlers (localhost mode). + @Test + public void localhostDestroyDropsHandlers() throws Exception { + SplitClientConfig config = SplitClientConfig.builder() + .splitFile("src/test/resources/splits_localhost.json") + .setBlockUntilReadyTimeout(10000) + .build(); + factory = new SplitFactoryImpl(config); + SplitClient client = factory.client(); + client.blockUntilReady(); + factory.destroy(); + + SdkLifecycleEventsIntegrationTest.Recorder afterDestroy = new SdkLifecycleEventsIntegrationTest.Recorder(); + client.on(SdkEvent.SDK_READY, afterDestroy); + await().during(QUIET_WINDOW).atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> afterDestroy.size() == 0); + assertTrue(factory.isDestroyed()); + } + + // Consumer mode emits SDK_READY (not from cache) but never SDK_UPDATE. + @Test + public void consumerFiresSdkReadyOnceWithoutCacheMetadataAndNeverRaisesAnUpdate() throws Exception { + CustomStorageWrapper wrapper = mock(CustomStorageWrapper.class); + when(wrapper.connect()).thenReturn(true); + SplitClientConfig config = SplitClientConfig.builder() + .operationMode(OperationMode.CONSUMER) + .customStorageWrapper(wrapper) + .setBlockUntilReadyTimeout(10000) + .build(); + factory = new SplitFactoryImpl("fake-api-token", config, wrapper); + SplitClient client = factory.client(); + SdkLifecycleEventsIntegrationTest.Recorder ready = new SdkLifecycleEventsIntegrationTest.Recorder(); + SdkLifecycleEventsIntegrationTest.Recorder updates = new SdkLifecycleEventsIntegrationTest.Recorder(); + client.on(SdkEvent.SDK_READY, ready); + client.on(SdkEvent.SDK_UPDATE, updates); + + client.blockUntilReady(); + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> ready.size() == 1); + assertFalse(ready.get(0).isInitialCacheLoad()); + + SdkLifecycleEventsIntegrationTest.Recorder late = new SdkLifecycleEventsIntegrationTest.Recorder(); + client.on(SdkEvent.SDK_READY, late); + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> late.size() == 1); + await().during(QUIET_WINDOW).atMost(WAIT_SECONDS, TimeUnit.SECONDS) + .until(() -> ready.size() == 1 && updates.size() == 0); + assertEquals(1, late.size()); + } +} diff --git a/client/src/test/java/io/split/client/SplitClientOnOffTest.java b/client/src/test/java/io/split/client/SplitClientOnOffTest.java new file mode 100644 index 00000000..d7cc0862 --- /dev/null +++ b/client/src/test/java/io/split/client/SplitClientOnOffTest.java @@ -0,0 +1,181 @@ +package io.split.client; + +import io.split.client.api.SdkEvent; +import io.split.client.api.SdkEventListener; +import io.split.client.api.SdkEventMetadata; +import io.split.client.api.SdkEventType; +import io.split.client.dtos.FallbackTreatmentCalculator; +import io.split.client.events.EventsStorageProducer; +import io.split.client.impressions.ImpressionsManager; +import io.split.client.interceptors.FlagSetsFilter; +import io.split.client.lifecycle.SdkEventsManager; +import io.split.client.lifecycle.SdkInternalEvent; +import io.split.engine.SDKReadinessGates; +import io.split.engine.evaluator.Evaluator; +import io.split.storages.SplitCacheConsumer; +import io.split.telemetry.storage.TelemetryStorage; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.awaitility.Awaitility.await; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; + +public class SplitClientOnOffTest { + + private SdkEventsManager _manager; + private SplitClientImpl _client; + + @Before + public void setUp() { + _manager = SdkEventsManager.create(null); + TelemetryStorage telemetry = mock(TelemetryStorage.class); + _client = new SplitClientImpl(mock(SplitFactory.class), mock(SplitCacheConsumer.class), mock(ImpressionsManager.class), + mock(EventsStorageProducer.class), SplitClientConfig.builder().build(), new SDKReadinessGates(), + mock(Evaluator.class), telemetry, telemetry, mock(FlagSetsFilter.class), + mock(FallbackTreatmentCalculator.class), _manager); + } + + @After + public void tearDown() { + _manager.destroy(); + } + + private void becomeReady() { + _manager.notifyInternalEvent(SdkInternalEvent.SDK_READY, SdkEventMetadata.ready(true, null)); + } + + private void flagUpdate(String name) { + _manager.notifyInternalEvent(SdkInternalEvent.FLAGS_UPDATED, + SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, Collections.singleton(name))); + } + + @Test + public void multipleListenersAndDuplicatesAreAllInvokedInOrder() { + becomeReady(); + List calls = new CopyOnWriteArrayList<>(); + SdkEventListener shared = metadata -> calls.add("shared"); + _client.on(SdkEvent.SDK_UPDATE, shared); + _client.on(SdkEvent.SDK_UPDATE, shared); + _client.on(SdkEvent.SDK_UPDATE, metadata -> calls.add("other")); + + flagUpdate("f1"); + + await().atMost(2, TimeUnit.SECONDS).until(() -> calls.size() == 3); + assertEquals(Arrays.asList("shared", "shared", "other"), calls); + } + + @Test + public void runnableOverloadRunsWithoutMetadata() { + becomeReady(); + AtomicInteger calls = new AtomicInteger(); + _client.on(SdkEvent.SDK_UPDATE, calls::incrementAndGet); + + flagUpdate("f1"); + + await().atMost(2, TimeUnit.SECONDS).until(() -> calls.get() == 1); + } + + @Test + public void listenerReceivesTheUpdateMetadata() { + becomeReady(); + List received = new CopyOnWriteArrayList<>(); + _client.on(SdkEvent.SDK_UPDATE, received::add); + + flagUpdate("f1"); + + await().atMost(2, TimeUnit.SECONDS).until(() -> received.size() == 1); + assertEquals(SdkEventType.FLAGS_UPDATE, received.get(0).getType()); + assertEquals(Collections.singleton("f1"), received.get(0).getNames()); + } + + @Test + public void lateSdkReadySubscriberIsReplayedWithNullMetadata() { + becomeReady(); + await().atMost(2, TimeUnit.SECONDS).until(() -> _manager.eventAlreadyTriggered(SdkEvent.SDK_READY)); + List received = new CopyOnWriteArrayList<>(); + _client.on(SdkEvent.SDK_READY, received::add); + + await().atMost(2, TimeUnit.SECONDS).until(() -> received.size() == 1); + assertNull(received.get(0)); + } + + @Test + public void offRemovesOnlyTheGivenEvent() throws InterruptedException { + AtomicInteger updates = new AtomicInteger(); + AtomicInteger ready = new AtomicInteger(); + _client.on(SdkEvent.SDK_UPDATE, updates::incrementAndGet); + _client.on(SdkEvent.SDK_READY, ready::incrementAndGet); + + _client.off(SdkEvent.SDK_UPDATE); + becomeReady(); + await().atMost(2, TimeUnit.SECONDS).until(() -> ready.get() == 1); + flagUpdate("f1"); + + Thread.sleep(200); + assertEquals(0, updates.get()); + } + + @Test + public void registeringAfterDestroyIsASilentNoOp() throws InterruptedException { + _manager.destroy(); + AtomicInteger calls = new AtomicInteger(); + + _client.on(SdkEvent.SDK_READY, calls::incrementAndGet); + _client.on(SdkEvent.SDK_READY, metadata -> calls.incrementAndGet()); + _client.off(SdkEvent.SDK_READY); + becomeReady(); + + Thread.sleep(200); + assertEquals(0, calls.get()); + } + + @Test + public void nullArgumentsAreIgnoredWithoutThrowing() throws InterruptedException { + becomeReady(); + AtomicInteger calls = new AtomicInteger(); + + _client.on(null, calls::incrementAndGet); + _client.on(null, (SdkEventListener) metadata -> calls.incrementAndGet()); + _client.on(SdkEvent.SDK_UPDATE, (Runnable) null); + _client.on(SdkEvent.SDK_UPDATE, (SdkEventListener) null); + _client.off(null); + flagUpdate("f1"); + + Thread.sleep(200); + assertEquals(0, calls.get()); + } + + @Test + public void clientWithoutAnEventsManagerIgnoresRegistrations() { + TelemetryStorage telemetry = mock(TelemetryStorage.class); + SplitClientImpl client = new SplitClientImpl(mock(SplitFactory.class), mock(SplitCacheConsumer.class), + mock(ImpressionsManager.class), mock(EventsStorageProducer.class), SplitClientConfig.builder().build(), + new SDKReadinessGates(), mock(Evaluator.class), telemetry, telemetry, mock(FlagSetsFilter.class), + mock(FallbackTreatmentCalculator.class)); + + client.on(SdkEvent.SDK_READY, () -> { }); + client.off(SdkEvent.SDK_READY); + } + + @Test + public void noCommonsTypeIsReachableFromThePublicApi() { + for (Method method : SplitClient.class.getMethods()) { + assertFalse(method.getName(), method.getReturnType().getName().startsWith("io.harness")); + for (Class parameter : method.getParameterTypes()) { + assertFalse(method.getName(), parameter.getName().startsWith("io.harness")); + } + } + } +} diff --git a/client/src/test/java/io/split/client/SplitFactoryImplEventsTest.java b/client/src/test/java/io/split/client/SplitFactoryImplEventsTest.java new file mode 100644 index 00000000..11843cea --- /dev/null +++ b/client/src/test/java/io/split/client/SplitFactoryImplEventsTest.java @@ -0,0 +1,123 @@ +package io.split.client; + +import io.split.client.api.SdkEvent; +import io.split.client.api.SdkEventMetadata; +import io.split.client.lifecycle.SdkEventsManager; +import io.split.client.lifecycle.SdkInternalEvent; +import io.split.storages.enums.OperationMode; +import org.junit.Test; +import pluggable.CustomStorageWrapper; + +import java.lang.reflect.Field; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class SplitFactoryImplEventsTest { + + private static SdkEventsManager eventsManagerOf(Object target) throws Exception { + Field field = target.getClass().getDeclaredField("_eventsManager"); + field.setAccessible(true); + return (SdkEventsManager) field.get(target); + } + + private static SplitFactoryImpl localhostFactory() throws Exception { + return new SplitFactoryImpl("localhost", SplitClientConfig.builder().setBlockUntilReadyTimeout(10000).build()); + } + + @Test + public void localhostFactoryBuildsOneManagerSharedWithTheClient() throws Exception { + SplitFactoryImpl factory = localhostFactory(); + try { + SdkEventsManager factoryManager = eventsManagerOf(factory); + assertNotNull(factoryManager); + assertSame(factoryManager, eventsManagerOf(factory.client())); + } finally { + factory.destroy(); + } + } + + @Test + public void destroyStopsDeliveryAndRegistrationAfterwardsIsNoOp() throws Exception { + SplitFactoryImpl factory = localhostFactory(); + SdkEventsManager manager = eventsManagerOf(factory); + AtomicInteger calls = new AtomicInteger(); + manager.on(SdkEvent.SDK_READY, metadata -> calls.incrementAndGet()); + + factory.destroy(); + + manager.on(SdkEvent.SDK_READY, metadata -> calls.incrementAndGet()); + manager.notifyInternalEvent(SdkInternalEvent.SDK_READY, SdkEventMetadata.ready(true, null)); + Thread.sleep(200); + assertTrue(factory.isDestroyed()); + assertFalse(manager.eventAlreadyTriggered(SdkEvent.SDK_READY)); + org.junit.Assert.assertEquals(0, calls.get()); + } + + @Test + public void destroyTwiceDoesNotThrow() throws Exception { + SplitFactoryImpl factory = localhostFactory(); + factory.destroy(); + factory.destroy(); + assertTrue(factory.isDestroyed()); + } + + @Test + public void localhostFactoryDeliversSdkReadyExactlyOnce() throws Exception { + SplitFactoryImpl factory = new SplitFactoryImpl(SplitClientConfig.builder() + .splitFile("src/test/resources/splits_localhost.json") + .setBlockUntilReadyTimeout(10000) + .build()); + try { + AtomicInteger calls = new AtomicInteger(); + eventsManagerOf(factory).on(SdkEvent.SDK_READY, metadata -> calls.incrementAndGet()); + org.awaitility.Awaitility.await().atMost(5, java.util.concurrent.TimeUnit.SECONDS).until(() -> calls.get() == 1); + Thread.sleep(300); + org.junit.Assert.assertEquals(1, calls.get()); + } finally { + factory.destroy(); + } + } + + @Test + public void consumerFactoryDeliversSdkReadyExactlyOnce() throws Exception { + CustomStorageWrapper wrapper = mock(CustomStorageWrapper.class); + org.mockito.Mockito.when(wrapper.connect()).thenReturn(true); + SplitClientConfig config = SplitClientConfig.builder() + .operationMode(OperationMode.CONSUMER) + .customStorageWrapper(wrapper) + .build(); + SplitFactoryImpl factory = new SplitFactoryImpl("token", config, wrapper); + try { + AtomicInteger calls = new AtomicInteger(); + eventsManagerOf(factory).on(SdkEvent.SDK_READY, metadata -> calls.incrementAndGet()); + org.awaitility.Awaitility.await().atMost(5, java.util.concurrent.TimeUnit.SECONDS).until(() -> calls.get() == 1); + Thread.sleep(300); + org.junit.Assert.assertEquals(1, calls.get()); + } finally { + factory.destroy(); + } + } + + @Test + public void consumerFactoryBuildsAManagerAndDestroysIt() throws Exception { + CustomStorageWrapper wrapper = mock(CustomStorageWrapper.class); + SplitClientConfig config = SplitClientConfig.builder() + .operationMode(OperationMode.CONSUMER) + .customStorageWrapper(wrapper) + .build(); + SplitFactoryImpl factory = new SplitFactoryImpl("token", config, wrapper); + SdkEventsManager manager = eventsManagerOf(factory); + assertNotNull(manager); + assertSame(manager, eventsManagerOf(factory.client())); + + factory.destroy(); + factory.destroy(); + assertTrue(factory.isDestroyed()); + assertFalse(manager.eventAlreadyTriggered(SdkEvent.SDK_READY)); + } +} diff --git a/client/src/test/java/io/split/client/SplitTimeoutEventsTest.java b/client/src/test/java/io/split/client/SplitTimeoutEventsTest.java new file mode 100644 index 00000000..a0b4174b --- /dev/null +++ b/client/src/test/java/io/split/client/SplitTimeoutEventsTest.java @@ -0,0 +1,124 @@ +package io.split.client; + +import io.split.client.api.SdkEvent; +import io.split.client.lifecycle.SdkEventsManager; +import io.split.client.lifecycle.SdkEventsNotifier; +import io.split.client.lifecycle.SdkInternalEvent; +import io.split.engine.SDKReadinessGates; +import io.split.engine.evaluator.Evaluator; +import io.split.client.impressions.ImpressionsManager; +import io.split.client.interceptors.FlagSetsFilter; +import io.split.client.dtos.FallbackTreatmentCalculator; +import io.split.client.events.EventsStorageProducer; +import io.split.storages.SplitCacheConsumer; +import io.split.telemetry.storage.TelemetryStorage; +import org.junit.Test; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.awaitility.Awaitility.await; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; + +public class SplitTimeoutEventsTest { + + private static final SplitClientConfig CONFIG = SplitClientConfig.builder().setBlockUntilReadyTimeout(50).build(); + + private static SplitClientImpl client(SDKReadinessGates gates, SdkEventsManager manager) { + TelemetryStorage telemetry = mock(TelemetryStorage.class); + return new SplitClientImpl(mock(SplitFactory.class), mock(SplitCacheConsumer.class), mock(ImpressionsManager.class), + mock(EventsStorageProducer.class), CONFIG, gates, mock(Evaluator.class), telemetry, telemetry, + mock(FlagSetsFilter.class), mock(FallbackTreatmentCalculator.class), manager); + } + + @Test + public void clientBlockUntilReadyTimeoutEmitsTimedOutOnceWithNullMetadata() throws Exception { + SdkEventsManager manager = SdkEventsManager.create(null); + AtomicInteger timedOut = new AtomicInteger(); + AtomicInteger ready = new AtomicInteger(); + manager.on(SdkEvent.SDK_READY_TIMED_OUT, metadata -> { + assertNull(metadata); + timedOut.incrementAndGet(); + }); + manager.on(SdkEvent.SDK_READY, metadata -> ready.incrementAndGet()); + SplitClientImpl client = client(new SDKReadinessGates(), manager); + + for (int i = 0; i < 2; i++) { + try { + client.blockUntilReady(); + org.junit.Assert.fail("expected TimeoutException"); + } catch (TimeoutException expected) { + // behaviour unchanged + } + } + + await().atMost(2, TimeUnit.SECONDS).until(() -> timedOut.get() == 1); + Thread.sleep(200); + assertEquals(1, timedOut.get()); + assertEquals(0, ready.get()); + manager.destroy(); + } + + @Test + public void readinessWinningTheRaceSuppressesTimedOut() throws Exception { + SdkEventsManager manager = SdkEventsManager.create(null); + AtomicInteger timedOut = new AtomicInteger(); + AtomicInteger ready = new AtomicInteger(); + manager.on(SdkEvent.SDK_READY_TIMED_OUT, metadata -> timedOut.incrementAndGet()); + manager.on(SdkEvent.SDK_READY, metadata -> ready.incrementAndGet()); + + manager.notifyInternalEvent(SdkInternalEvent.SDK_READY, io.split.client.api.SdkEventMetadata.ready(true, null)); + await().atMost(2, TimeUnit.SECONDS).until(() -> ready.get() == 1); + manager.notifyInternalEvent(SdkInternalEvent.SDK_READY_TIMEOUT_REACHED, null); + + Thread.sleep(300); + assertEquals(1, ready.get()); + assertEquals(0, timedOut.get()); + manager.destroy(); + } + + @Test + public void managerBlockUntilReadyTimeoutNotifiesTimeoutReached() throws Exception { + SdkEventsNotifier notifier = mock(SdkEventsNotifier.class); + SplitManagerImpl manager = new SplitManagerImpl(mock(SplitCacheConsumer.class), CONFIG, new SDKReadinessGates(), + mock(TelemetryStorage.class), notifier); + + try { + manager.blockUntilReady(); + org.junit.Assert.fail("expected TimeoutException"); + } catch (TimeoutException expected) { + // behaviour unchanged + } + + verify(notifier).notify(eq(SdkInternalEvent.SDK_READY_TIMEOUT_REACHED), eq(null)); + } + + @Test + public void blockUntilReadyThatSucceedsNotifiesNothing() throws Exception { + SDKReadinessGates gates = new SDKReadinessGates(); + gates.sdkInternalReady(); + SdkEventsNotifier notifier = mock(SdkEventsNotifier.class); + new SplitManagerImpl(mock(SplitCacheConsumer.class), CONFIG, gates, mock(TelemetryStorage.class), notifier) + .blockUntilReady(); + verifyZeroInteractions(notifier); + } + + @Test(expected = IllegalArgumentException.class) + public void nonPositiveTimeoutStillThrowsIllegalArgument() throws Exception { + SdkEventsNotifier notifier = mock(SdkEventsNotifier.class); + try { + new SplitManagerImpl(mock(SplitCacheConsumer.class), SplitClientConfig.builder().build(), new SDKReadinessGates(), + mock(TelemetryStorage.class), notifier).blockUntilReady(); + } finally { + verify(notifier, never()).notify(any(SdkInternalEvent.class), any(io.split.client.api.SdkEventMetadata.class)); + } + } +} diff --git a/client/src/test/java/io/split/client/api/SdkEventMetadataTest.java b/client/src/test/java/io/split/client/api/SdkEventMetadataTest.java new file mode 100644 index 00000000..52ce6293 --- /dev/null +++ b/client/src/test/java/io/split/client/api/SdkEventMetadataTest.java @@ -0,0 +1,84 @@ +package io.split.client.api; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class SdkEventMetadataTest { + + @Test + public void updateRetainsNames() { + SdkEventMetadata m = SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, Arrays.asList("a", "b")); + assertEquals(SdkEventType.FLAGS_UPDATE, m.getType()); + assertEquals(2, m.getNames().size()); + assertTrue(m.getNames().contains("a")); + assertTrue(m.getNames().contains("b")); + } + + @Test + public void updateDropsNullAndBlankNames() { + SdkEventMetadata m = SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, Arrays.asList("a", null, "", " ")); + assertEquals(Collections.singleton("a"), m.getNames()); + } + + @Test + public void updateWithNullCollectionYieldsEmptyNames() { + assertTrue(SdkEventMetadata.update(SdkEventType.SEGMENTS_UPDATE, null).getNames().isEmpty()); + } + + @Test(expected = UnsupportedOperationException.class) + public void namesAreUnmodifiable() { + SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, Arrays.asList("a")).getNames().add("b"); + } + + @Test + public void namesAreDefensivelyCopied() { + List source = new ArrayList<>(Arrays.asList("a")); + SdkEventMetadata m = SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, source); + source.add("b"); + assertEquals(Collections.singleton("a"), m.getNames()); + } + + @Test + public void updateHasNoReadyFields() { + SdkEventMetadata m = SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, Arrays.asList("a")); + assertFalse(m.isInitialCacheLoad()); + assertNull(m.getLastUpdateTimestamp()); + } + + @Test + public void readyStandaloneShape() { + SdkEventMetadata m = SdkEventMetadata.ready(true, null); + assertTrue(m.isInitialCacheLoad()); + assertNull(m.getLastUpdateTimestamp()); + assertNull(m.getType()); + assertTrue(m.getNames().isEmpty()); + } + + @Test + public void readyConsumerShape() { + SdkEventMetadata m = SdkEventMetadata.ready(false, null); + assertFalse(m.isInitialCacheLoad()); + assertNull(m.getLastUpdateTimestamp()); + } + + @Test + public void equalsAndHashCode() { + SdkEventMetadata a = SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, Arrays.asList("x")); + SdkEventMetadata b = SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, Arrays.asList("x")); + SdkEventMetadata c = SdkEventMetadata.update(SdkEventType.SEGMENTS_UPDATE, Arrays.asList("x")); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertNotEquals(a, c); + assertNotEquals(SdkEventMetadata.ready(true, 1L), SdkEventMetadata.ready(true, 2L)); + } +} diff --git a/client/src/test/java/io/split/client/lifecycle/EventThreads.java b/client/src/test/java/io/split/client/lifecycle/EventThreads.java new file mode 100644 index 00000000..c8e23f4f --- /dev/null +++ b/client/src/test/java/io/split/client/lifecycle/EventThreads.java @@ -0,0 +1,43 @@ +package io.split.client.lifecycle; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; + +/** + * Leak check for the event-delivery thread. Other tests sharing the JVM may leave their own event threads running (or + * start one lazily at any moment), so instead of scanning all threads a test records the threads that delivered events + * to its own listeners and afterwards asserts that exactly those have terminated. + */ +public final class EventThreads { + + private static final Set DELIVERED_ON = Collections.synchronizedSet(new HashSet()); + + private EventThreads() { + } + + /** Call from a listener: remembers the thread the event was delivered on. */ + public static void record() { + DELIVERED_ON.add(Thread.currentThread()); + } + + public static void awaitRecordedThreadsTerminated() { + try { + await().atMost(10, TimeUnit.SECONDS).until(() -> { + synchronized (DELIVERED_ON) { + for (Thread t : DELIVERED_ON) { + if (t.isAlive()) { + return false; + } + } + return true; + } + }); + } finally { + DELIVERED_ON.clear(); + } + } +} diff --git a/client/src/test/java/io/split/client/lifecycle/LifecycleScriptDispatcher.java b/client/src/test/java/io/split/client/lifecycle/LifecycleScriptDispatcher.java new file mode 100644 index 00000000..fab8630b --- /dev/null +++ b/client/src/test/java/io/split/client/lifecycle/LifecycleScriptDispatcher.java @@ -0,0 +1,136 @@ +package io.split.client.lifecycle; + +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.RecordedRequest; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Stateful mock Split backend for the lifecycle event tests. Every endpoint answers "nothing changed" unless a + * response was scripted for the change number being requested, which lets a test publish exactly one change at + * the moment it chooses. + */ +final class LifecycleScriptDispatcher extends Dispatcher { + + static final String SEGMENT = "seg1"; + + private static final String AUTH_BODY = + "{\"pushEnabled\":true,\"token\":\"khdkjdahs987498217.eyJ4LWFibHktY2FwYWJpbGl0eSI6IntcInh4eHhfeHh4eF9zZWdtZW50c1wiOltcInN1YnNjcmliZVwiXSxcInh4eHhfeHh4eF9zcGxpdHNcIjpbXCJzdWJzY3JpYmVcIl0sXCJjb250cm9sXCI6W1wic3Vic2NyaWJlXCJdfSIsImV4cCI6MTU5MzAyNzQxMCwiaWF0IjoxNTkzMDIzODEwfQ\"}"; + + private final String initialFlags; + private final String initialSegment; + private final long headersDelayMillis; + private final Map flagScripts = new ConcurrentHashMap<>(); + private final Map segmentScripts = new ConcurrentHashMap<>(); + private final AtomicLong flagRequests = new AtomicLong(); + + LifecycleScriptDispatcher(String initialFlags, String initialSegment, long headersDelayMillis) { + this.initialFlags = initialFlags; + this.initialSegment = initialSegment; + this.headersDelayMillis = headersDelayMillis; + } + + /** Body served the next time the SDK asks for flag changes since {@code since}. */ + void scriptFlags(long since, String body) { + flagScripts.put(since, body); + } + + /** Body served the next time the SDK asks for changes of {@link #SEGMENT} since {@code since}. */ + void scriptSegment(long since, String body) { + segmentScripts.put(since, body); + } + + long flagRequests() { + return flagRequests.get(); + } + + @Override + public MockResponse dispatch(RecordedRequest request) { + String path = request.getPath(); + if (path.startsWith("/api/auth")) { + return new MockResponse().setBody(AUTH_BODY); + } + if (path.startsWith("/api/splitChanges")) { + flagRequests.incrementAndGet(); + long since = Long.parseLong(request.getRequestUrl().queryParameter("since")); + long rbSince = Long.parseLong(request.getRequestUrl().queryParameter("rbSince")); + MockResponse response = new MockResponse(); + if (headersDelayMillis > 0) { + response.setHeadersDelay(headersDelayMillis, java.util.concurrent.TimeUnit.MILLISECONDS); + } + if (since == -1) { + return response.setBody(initialFlags); + } + String scripted = flagScripts.get(since); + return response.setBody(scripted != null ? scripted : noFlagChanges(since, rbSince)); + } + if (path.startsWith("/api/segmentChanges/")) { + long since = Long.parseLong(request.getRequestUrl().queryParameter("since")); + if (since == -1) { + return new MockResponse().setBody(initialSegment); + } + String scripted = segmentScripts.get(since); + return new MockResponse().setBody(scripted != null ? scripted : noSegmentChanges(since)); + } + // telemetry, impressions, events + return new MockResponse().setResponseCode(200); + } + + static String noFlagChanges(long since, long rbSince) { + return "{\"ff\":{\"d\":[],\"s\":" + since + ",\"t\":" + since + "},\"rbs\":{\"d\":[],\"s\":" + rbSince + + ",\"t\":" + rbSince + "}}"; + } + + static String noSegmentChanges(long since) { + return segmentChange(since, since, "", ""); + } + + static String segmentChange(long since, long till, String addedCsv, String removedCsv) { + return "{\"name\":\"" + SEGMENT + "\",\"added\":[" + quoted(addedCsv) + "],\"removed\":[" + quoted(removedCsv) + + "],\"since\":" + since + ",\"till\":" + till + "}"; + } + + /** Flags body for a first fetch (since=-1) or a scripted change; {@code rbs} may be empty. */ + static String flagsBody(long since, long till, String flagsJson, long rbSince, long rbTill, String rbsJson) { + return "{\"ff\":{\"d\":[" + flagsJson + "],\"s\":" + since + ",\"t\":" + till + "},\"rbs\":{\"d\":[" + + rbsJson + "],\"s\":" + rbSince + ",\"t\":" + rbTill + "}}"; + } + + static String flag(String name, long changeNumber, boolean inSegment) { + String matcher = inSegment + ? "{\"keySelector\":{\"trafficType\":\"user\",\"attribute\":null},\"matcherType\":\"IN_SEGMENT\"," + + "\"negate\":false,\"userDefinedSegmentMatcherData\":{\"segmentName\":\"" + SEGMENT + "\"}}" + : "{\"keySelector\":{\"trafficType\":\"user\",\"attribute\":null},\"matcherType\":\"ALL_KEYS\"," + + "\"negate\":false}"; + return "{\"trafficTypeName\":\"user\",\"name\":\"" + name + "\",\"trafficAllocation\":100," + + "\"trafficAllocationSeed\":-2092979940,\"seed\":105482719,\"status\":\"ACTIVE\",\"killed\":false," + + "\"defaultTreatment\":\"off\",\"changeNumber\":" + changeNumber + ",\"algo\":2,\"configurations\":{}," + + "\"conditions\":[{\"conditionType\":\"ROLLOUT\",\"matcherGroup\":{\"combiner\":\"AND\",\"matchers\":[" + + matcher + "]},\"partitions\":[{\"treatment\":\"on\",\"size\":100},{\"treatment\":\"off\",\"size\":0}]," + + "\"label\":\"default rule\"}]}"; + } + + static String ruleBasedSegment(String name, long changeNumber) { + return "{\"changeNumber\":" + changeNumber + ",\"name\":\"" + name + "\",\"status\":\"ACTIVE\"," + + "\"trafficTypeName\":\"user\",\"excluded\":{\"keys\":[],\"segments\":[]},\"conditions\":[" + + "{\"matcherGroup\":{\"combiner\":\"AND\",\"matchers\":[{\"keySelector\":{\"trafficType\":\"user\"," + + "\"attribute\":null},\"matcherType\":\"ALL_KEYS\",\"negate\":false}]}}]}"; + } + + private static String quoted(String csv) { + if (csv.isEmpty()) { + return ""; + } + StringBuilder out = new StringBuilder(); + for (String item : csv.split(",")) { + if (out.length() > 0) { + out.append(','); + } + out.append('"').append(item).append('"'); + } + return out.toString(); + } +} diff --git a/client/src/test/java/io/split/client/lifecycle/SdkEventsConfigTest.java b/client/src/test/java/io/split/client/lifecycle/SdkEventsConfigTest.java new file mode 100644 index 00000000..be93ad9c --- /dev/null +++ b/client/src/test/java/io/split/client/lifecycle/SdkEventsConfigTest.java @@ -0,0 +1,52 @@ +package io.split.client.lifecycle; + +import io.harness.events.EventsManagerConfig; +import io.split.client.api.SdkEvent; +import org.junit.Test; + +import java.util.Collections; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.Is.is; +import static org.hamcrest.core.IsCollectionContaining.hasItem; +import static org.junit.Assert.assertTrue; + +public class SdkEventsConfigTest { + + @Test + public void executionLimitsMatchOnceOnlyAndUnlimitedEvents() { + EventsManagerConfig config = SdkEventsConfig.build(); + assertThat(config.getExecutionLimits().get(SdkEvent.SDK_READY), is(1)); + assertThat(config.getExecutionLimits().get(SdkEvent.SDK_READY_TIMED_OUT), is(1)); + assertThat(config.getExecutionLimits().get(SdkEvent.SDK_UPDATE), is(-1)); + } + + @Test + public void sdkUpdateRequiresSdkReadyAsPrerequisite() { + EventsManagerConfig config = SdkEventsConfig.build(); + assertThat(config.getPrerequisites().get(SdkEvent.SDK_UPDATE), is(Collections.singleton(SdkEvent.SDK_READY))); + } + + @Test + public void evaluationOrderPlacesSdkReadyBeforeSdkUpdate() { + EventsManagerConfig config = SdkEventsConfig.build(); + java.util.List order = config.getEvaluationOrder(); + assertTrue(order.indexOf(SdkEvent.SDK_READY) < order.indexOf(SdkEvent.SDK_UPDATE)); + } + + @Test + public void sdkUpdateRequiresAnyOfTheFourUpdateInternalEvents() { + EventsManagerConfig config = SdkEventsConfig.build(); + java.util.Set> groups = config.getRequireAny().get(SdkEvent.SDK_UPDATE); + assertThat(groups, hasItem(Collections.singleton(SdkInternalEvent.FLAGS_UPDATED))); + assertThat(groups, hasItem(Collections.singleton(SdkInternalEvent.FLAG_KILLED_NOTIFICATION))); + assertThat(groups, hasItem(Collections.singleton(SdkInternalEvent.SEGMENTS_UPDATED))); + assertThat(groups, hasItem(Collections.singleton(SdkInternalEvent.RULE_BASED_SEGMENTS_UPDATED))); + } + + @Test + public void buildDoesNotThrow() { + // Cycle detection runs inside build(); a circular prerequisite/suppressedBy graph would throw here. + SdkEventsConfig.build(); + } +} diff --git a/client/src/test/java/io/split/client/lifecycle/SdkEventsManagerTest.java b/client/src/test/java/io/split/client/lifecycle/SdkEventsManagerTest.java new file mode 100644 index 00000000..8731164e --- /dev/null +++ b/client/src/test/java/io/split/client/lifecycle/SdkEventsManagerTest.java @@ -0,0 +1,253 @@ +package io.split.client.lifecycle; + +import io.split.client.api.SdkEvent; +import io.split.client.api.SdkEventMetadata; +import io.split.client.api.SdkEventType; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.awaitility.Awaitility.await; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.Is.is; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class SdkEventsManagerTest { + + @Test + public void listenersOnSameEventFireInRegistrationOrder() { + SdkEventsManager manager = SdkEventsManager.create(null); + List order = new CopyOnWriteArrayList<>(); + manager.notifyInternalEvent(SdkInternalEvent.SDK_READY, SdkEventMetadata.ready(true, null)); + manager.on(SdkEvent.SDK_UPDATE, metadata -> order.add(1)); + manager.on(SdkEvent.SDK_UPDATE, metadata -> order.add(2)); + manager.on(SdkEvent.SDK_UPDATE, metadata -> order.add(3)); + + manager.notifyInternalEvent(SdkInternalEvent.FLAGS_UPDATED, SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, Arrays.asList("a"))); + + await().atMost(2, TimeUnit.SECONDS).until(() -> order.size() == 3); + assertEquals(Arrays.asList(1, 2, 3), order); + manager.destroy(); + } + + @Test + public void aThrowingListenerDoesNotBlockOthers() { + SdkEventsManager manager = SdkEventsManager.create(null); + AtomicInteger secondCalls = new AtomicInteger(); + manager.notifyInternalEvent(SdkInternalEvent.SDK_READY, SdkEventMetadata.ready(true, null)); + manager.on(SdkEvent.SDK_UPDATE, metadata -> { + throw new RuntimeException("boom"); + }); + manager.on(SdkEvent.SDK_UPDATE, metadata -> secondCalls.incrementAndGet()); + + manager.notifyInternalEvent(SdkInternalEvent.FLAGS_UPDATED, SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, Arrays.asList("a"))); + + await().atMost(2, TimeUnit.SECONDS).until(() -> secondCalls.get() == 1); + manager.destroy(); + } + + @Test + public void sdkReadyFiresOnceAndReplaysNullToLateSubscriber() { + SdkEventsManager manager = SdkEventsManager.create(null); + List earlyReceived = new CopyOnWriteArrayList<>(); + manager.on(SdkEvent.SDK_READY, earlyReceived::add); + + manager.notifyInternalEvent(SdkInternalEvent.SDK_READY, SdkEventMetadata.ready(true, 123L)); + await().atMost(2, TimeUnit.SECONDS).until(() -> earlyReceived.size() == 1); + assertThat(earlyReceived.get(0).isInitialCacheLoad(), is(true)); + + List lateReceived = new CopyOnWriteArrayList<>(); + manager.on(SdkEvent.SDK_READY, lateReceived::add); + await().atMost(2, TimeUnit.SECONDS).until(() -> lateReceived.size() == 1); + assertNull(lateReceived.get(0)); + manager.destroy(); + } + + @Test + public void sdkUpdateNeverReplaysToLateSubscriber() throws InterruptedException { + SdkEventsManager manager = SdkEventsManager.create(null); + manager.notifyInternalEvent(SdkInternalEvent.SDK_READY, SdkEventMetadata.ready(true, null)); + manager.notifyInternalEvent(SdkInternalEvent.FLAGS_UPDATED, SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, Arrays.asList("a"))); + // give the first update time to be fully delivered before the late listener registers + Thread.sleep(200); + + AtomicInteger lateCalls = new AtomicInteger(); + manager.on(SdkEvent.SDK_UPDATE, metadata -> lateCalls.incrementAndGet()); + Thread.sleep(200); + assertEquals(0, lateCalls.get()); + + manager.notifyInternalEvent(SdkInternalEvent.FLAGS_UPDATED, SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, Arrays.asList("b"))); + await().atMost(2, TimeUnit.SECONDS).until(() -> lateCalls.get() == 1); + manager.destroy(); + } + + @Test + public void sdkUpdateRequiresSdkReadyFirst() throws InterruptedException { + SdkEventsManager manager = SdkEventsManager.create(null); + AtomicInteger updateCalls = new AtomicInteger(); + manager.on(SdkEvent.SDK_UPDATE, metadata -> updateCalls.incrementAndGet()); + + manager.notifyInternalEvent(SdkInternalEvent.FLAGS_UPDATED, SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, Arrays.asList("a"))); + Thread.sleep(200); + assertEquals(0, updateCalls.get()); + + manager.notifyInternalEvent(SdkInternalEvent.SDK_READY, SdkEventMetadata.ready(true, null)); + Thread.sleep(200); + assertEquals(0, updateCalls.get()); + + manager.notifyInternalEvent(SdkInternalEvent.FLAGS_UPDATED, SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, Arrays.asList("a"))); + await().atMost(2, TimeUnit.SECONDS).until(() -> updateCalls.get() == 1); + manager.destroy(); + } + + @Test + public void sdkUpdateMetadataReflectsWhicheverInternalEventFired() { + SdkEventsManager flagsManager = SdkEventsManager.create(null); + List flagsReceived = new CopyOnWriteArrayList<>(); + flagsManager.notifyInternalEvent(SdkInternalEvent.SDK_READY, SdkEventMetadata.ready(true, null)); + flagsManager.on(SdkEvent.SDK_UPDATE, flagsReceived::add); + flagsManager.notifyInternalEvent(SdkInternalEvent.FLAGS_UPDATED, SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, Arrays.asList("a"))); + await().atMost(2, TimeUnit.SECONDS).until(() -> flagsReceived.size() == 1); + assertEquals(SdkEventType.FLAGS_UPDATE, flagsReceived.get(0).getType()); + assertEquals(Collections.singleton("a"), flagsReceived.get(0).getNames()); + flagsManager.destroy(); + + SdkEventsManager segmentsManager = SdkEventsManager.create(null); + List segmentsReceived = new CopyOnWriteArrayList<>(); + segmentsManager.notifyInternalEvent(SdkInternalEvent.SDK_READY, SdkEventMetadata.ready(true, null)); + segmentsManager.on(SdkEvent.SDK_UPDATE, segmentsReceived::add); + segmentsManager.notifyInternalEvent(SdkInternalEvent.SEGMENTS_UPDATED, SdkEventMetadata.update(SdkEventType.SEGMENTS_UPDATE, Collections.emptyList())); + await().atMost(2, TimeUnit.SECONDS).until(() -> segmentsReceived.size() == 1); + assertEquals(SdkEventType.SEGMENTS_UPDATE, segmentsReceived.get(0).getType()); + segmentsManager.destroy(); + } + + @Test + public void offClearsListenersButDispatcherKeepsWorking() { + SdkEventsManager manager = SdkEventsManager.create(null); + manager.notifyInternalEvent(SdkInternalEvent.SDK_READY, SdkEventMetadata.ready(true, null)); + + AtomicInteger firstListenerCalls = new AtomicInteger(); + manager.on(SdkEvent.SDK_UPDATE, metadata -> firstListenerCalls.incrementAndGet()); + manager.notifyInternalEvent(SdkInternalEvent.FLAGS_UPDATED, SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, Arrays.asList("a"))); + await().atMost(2, TimeUnit.SECONDS).until(() -> firstListenerCalls.get() == 1); + + manager.off(SdkEvent.SDK_UPDATE); + AtomicInteger secondListenerCalls = new AtomicInteger(); + manager.on(SdkEvent.SDK_UPDATE, metadata -> secondListenerCalls.incrementAndGet()); + + manager.notifyInternalEvent(SdkInternalEvent.FLAGS_UPDATED, SdkEventMetadata.update(SdkEventType.FLAGS_UPDATE, Arrays.asList("b"))); + await().atMost(2, TimeUnit.SECONDS).until(() -> secondListenerCalls.get() == 1); + assertEquals(1, firstListenerCalls.get()); + manager.destroy(); + } + + @Test + public void registrationIsNoOpAfterDestroy() throws InterruptedException { + SdkEventsManager manager = SdkEventsManager.create(null); + manager.destroy(); + + AtomicInteger calls = new AtomicInteger(); + manager.on(SdkEvent.SDK_READY, metadata -> calls.incrementAndGet()); + manager.notifyInternalEvent(SdkInternalEvent.SDK_READY, SdkEventMetadata.ready(true, null)); + Thread.sleep(200); + assertEquals(0, calls.get()); + } + + @Test + public void notifyAndTriggeredQueryAfterDestroyAreNoOps() { + SdkEventsManager manager = SdkEventsManager.create(null); + manager.destroy(); + + manager.notifyInternalEvent(SdkInternalEvent.SDK_READY, SdkEventMetadata.ready(true, null)); + assertEquals(false, manager.eventAlreadyTriggered(SdkEvent.SDK_READY)); + } + + @Test + public void offAfterDestroyIsNoOp() { + SdkEventsManager manager = SdkEventsManager.create(null); + manager.destroy(); + + manager.off(SdkEvent.SDK_UPDATE); + } + + @Test + public void inFlightDispatchAfterDestroyIsDroppedSilently() throws Exception { + SdkEventsManager manager = SdkEventsManager.create(null); + manager.destroy(); + + java.lang.reflect.Method dispatch = SdkEventsManager.class.getDeclaredMethod("dispatch", SdkEvent.class, SdkEventMetadata.class); + dispatch.setAccessible(true); + dispatch.invoke(manager, SdkEvent.SDK_UPDATE, null); + } + + @Test + public void deliveryAfterExecutorShutdownIsDroppedWithoutThrowing() throws Exception { + SdkEventsManager manager = SdkEventsManager.create(null); + AtomicInteger readyCalls = new AtomicInteger(); + manager.on(SdkEvent.SDK_READY, metadata -> readyCalls.incrementAndGet()); + manager.notifyInternalEvent(SdkInternalEvent.SDK_READY, SdkEventMetadata.ready(true, null)); + await().atMost(2, TimeUnit.SECONDS).until(() -> readyCalls.get() == 1); + + java.lang.reflect.Field executorField = SdkEventsManager.class.getDeclaredField("deliveryExecutor"); + executorField.setAccessible(true); + ((java.util.concurrent.ExecutorService) executorField.get(manager)).shutdown(); + + // replay path: delivered already contains SDK_READY, so on() submits to the shut-down executor + manager.on(SdkEvent.SDK_READY, metadata -> { }); + // trigger path: commons calls deliverAsync, which submits to the shut-down executor + manager.notifyInternalEvent(SdkInternalEvent.SDK_READY_TIMEOUT_REACHED, null); + Thread.sleep(200); + manager.destroy(); + } + + @Test + public void concurrentRegistrationRacingATriggerDeliversExactlyOnceEach() throws InterruptedException { + SdkEventsManager manager = SdkEventsManager.create(null); + int listenerCount = 50; + AtomicInteger totalCalls = new AtomicInteger(); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(listenerCount); + + for (int i = 0; i < listenerCount; i++) { + Thread t = new Thread(() -> { + try { + startLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + manager.on(SdkEvent.SDK_READY, metadata -> totalCalls.incrementAndGet()); + doneLatch.countDown(); + }); + t.start(); + } + + startLatch.countDown(); + manager.notifyInternalEvent(SdkInternalEvent.SDK_READY, SdkEventMetadata.ready(true, null)); + doneLatch.await(5, TimeUnit.SECONDS); + + await().atMost(3, TimeUnit.SECONDS).until(() -> totalCalls.get() == listenerCount); + assertEquals(listenerCount, totalCalls.get()); + manager.destroy(); + } + + @Test + public void sdkReadyTimedOutDeliversNullMetadata() { + SdkEventsManager manager = SdkEventsManager.create(null); + List received = new CopyOnWriteArrayList<>(); + manager.on(SdkEvent.SDK_READY_TIMED_OUT, received::add); + + manager.notifyInternalEvent(SdkInternalEvent.SDK_READY_TIMEOUT_REACHED, null); + + await().atMost(2, TimeUnit.SECONDS).until(() -> received.size() == 1); + assertNull(received.get(0)); + manager.destroy(); + } +} diff --git a/client/src/test/java/io/split/client/lifecycle/SdkLifecycleEventsIntegrationTest.java b/client/src/test/java/io/split/client/lifecycle/SdkLifecycleEventsIntegrationTest.java new file mode 100644 index 00000000..868cf851 --- /dev/null +++ b/client/src/test/java/io/split/client/lifecycle/SdkLifecycleEventsIntegrationTest.java @@ -0,0 +1,412 @@ +package io.split.client.lifecycle; + +import io.split.SSEMockServer; +import io.split.SplitMockServer; +import io.split.client.SplitClient; +import io.split.client.SplitClientConfig; +import io.split.client.SplitFactory; +import io.split.client.SplitFactoryBuilder; +import io.split.client.api.SdkEvent; +import io.split.client.api.SdkEventMetadata; +import io.split.client.api.SdkEventType; +import org.glassfish.grizzly.utils.Pair; +import org.glassfish.jersey.media.sse.OutboundEvent; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static io.split.client.lifecycle.LifecycleScriptDispatcher.SEGMENT; +import static io.split.client.lifecycle.LifecycleScriptDispatcher.flag; +import static io.split.client.lifecycle.LifecycleScriptDispatcher.flagsBody; +import static io.split.client.lifecycle.LifecycleScriptDispatcher.ruleBasedSegment; +import static io.split.client.lifecycle.LifecycleScriptDispatcher.segmentChange; +import static org.awaitility.Awaitility.await; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * End-to-end tests for the SDK lifecycle events (SDK_READY, SDK_UPDATE, SDK_READY_TIMED_OUT) driving a real standalone + * {@link SplitFactory} against a scripted mock backend, over both polling and streaming. Localhost and consumer modes + * are covered by {@link SdkLifecycleEventsModesIntegrationTest}. + * + *

Out of scope here because they only exist in the client-side SDKs: the "sdkReadyFromCache" scenario and the + * per-client fan-out scenario (EventsManagerCoordinator). + */ +@RunWith(Parameterized.class) +public class SdkLifecycleEventsIntegrationTest { + + private static final long INITIAL_CN = 100L; + private static final long UPDATED_CN = 200L; + private static final long SEGMENT_CN = 10L; + private static final long SEGMENT_UPDATED_CN = 20L; + private static final long WAIT_SECONDS = 20L; + // The segment refresh rate has a 30 second floor, so a polled segment change takes up to that long to arrive. + private static final long SEGMENT_POLL_WAIT_SECONDS = 70L; + private static final Duration QUIET_WINDOW = Duration.ofMillis(1500); + + @Parameterized.Parameters(name = "streaming={0}") + public static Collection modes() { + return Arrays.asList(new Object[][] {{false}, {true}}); + } + + private final boolean streaming; + private LifecycleScriptDispatcher backend; + private SplitMockServer splitServer; + private SSEMockServer sseServer; + private SSEMockServer.SseEventQueue sseQueue; + private SplitFactory factory; + + public SdkLifecycleEventsIntegrationTest(boolean streaming) { + this.streaming = streaming; + } + + @Before + public void startBackend() throws Exception { + newBackend(0); + } + + @After + public void stopEverything() throws Exception { + if (factory != null) { + factory.destroy(); + } + if (sseServer != null) { + sseServer.stop(); + } + if (splitServer != null) { + splitServer.stop(); + } + // No leaked event-delivery thread once the factory is destroyed (threads that delivered to this test's listeners). + EventThreads.awaitRecordedThreadsTerminated(); + } + + // Scenario: sdkReady fires once the SDK is ready, and replays to a subscriber that registers after the fact. + @Test + public void sdkReadyFiresOnceAndReplaysToLateSubscribers() throws Exception { + SplitClient client = buildClient(10000); + Recorder early = new Recorder(); + client.on(SdkEvent.SDK_READY, early); + + client.blockUntilReady(); + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> early.size() == 1); + + Recorder late = new Recorder(); + client.on(SdkEvent.SDK_READY, late); + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> late.size() == 1); + assertNull("a replayed SDK_READY carries no metadata", late.get(0)); + + // later syncs (and later registrations) never fire it again + publishFlagChange(flagsBody(INITIAL_CN, UPDATED_CN, flag("f1", UPDATED_CN, true), -1, -1, "")); + await().during(QUIET_WINDOW).atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> early.size() == 1); + assertEquals(1, late.size()); + } + + // Scenario: sdkUpdate fires only after sdkReady (the initial load is not an update). + @Test + public void sdkUpdateIsNotRaisedByTheInitialLoadOnlyByLaterChanges() throws Exception { + SplitClient client = buildClient(10000); + Recorder ready = new Recorder(); + Recorder updates = new Recorder(); + client.on(SdkEvent.SDK_READY, ready); + client.on(SdkEvent.SDK_UPDATE, updates); + + client.blockUntilReady(); + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> ready.size() == 1); + await().during(QUIET_WINDOW).atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> updates.size() == 0); + + publishFlagChange(flagsBody(INITIAL_CN, UPDATED_CN, flag("f1", UPDATED_CN, true), -1, -1, "")); + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> updates.size() == 1); + } + + // Scenario: sdkUpdate does not replay to a subscriber that registers after an update already happened. + @Test + public void sdkUpdateDoesNotReplayToLateSubscribers() throws Exception { + SplitClient client = buildClient(10000); + Recorder first = new Recorder(); + client.on(SdkEvent.SDK_UPDATE, first); + client.blockUntilReady(); + + publishFlagChange(flagsBody(INITIAL_CN, UPDATED_CN, flag("f1", UPDATED_CN, true), -1, -1, "")); + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> first.size() == 1); + + Recorder late = new Recorder(); + client.on(SdkEvent.SDK_UPDATE, late); + await().during(QUIET_WINDOW).atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> late.size() == 0); + assertEquals(1, first.size()); + } + + // Scenario: sdkReadyTimedOut fires when the SDK does not become ready within the timeout. + @Test + public void sdkReadyTimedOutFiresWhenTheSdkIsNotReadyInTime() throws Exception { + stopBackend(); + newBackend(4000); + SplitClient client = buildClient(300); + Recorder timedOut = new Recorder(); + Recorder ready = new Recorder(); + client.on(SdkEvent.SDK_READY_TIMED_OUT, timedOut); + client.on(SdkEvent.SDK_READY, ready); + + try { + client.blockUntilReady(); + fail("the backend is slower than the timeout, blockUntilReady must time out"); + } catch (TimeoutException expected) { + // expected + } + + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> timedOut.size() == 1); + assertNull(timedOut.get(0)); + assertEquals(0, ready.size()); + + Recorder lateTimedOut = new Recorder(); + client.on(SdkEvent.SDK_READY_TIMED_OUT, lateTimedOut); + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> lateTimedOut.size() == 1); + } + + // Scenario: sdkReadyTimedOut is suppressed when sdkReady wins. + @Test + public void sdkReadyTimedOutIsSuppressedWhenTheSdkIsReady() throws Exception { + SplitClient client = buildClient(10000); + Recorder ready = new Recorder(); + Recorder timedOut = new Recorder(); + client.on(SdkEvent.SDK_READY, ready); + client.on(SdkEvent.SDK_READY_TIMED_OUT, timedOut); + + client.blockUntilReady(); + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> ready.size() == 1); + + // already ready: another wait neither times out nor raises the event + client.blockUntilReady(); + await().during(QUIET_WINDOW).atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> timedOut.size() == 0); + } + + // Scenario: listeners on one event are invoked sequentially, in registration order, with error isolation. + @Test + public void listenersRunSequentiallyInRegistrationOrderAndAThrowingOneDoesNotBlockTheRest() throws Exception { + SplitClient client = buildClient(10000); + List order = new CopyOnWriteArrayList<>(); + List threads = new CopyOnWriteArrayList<>(); + client.on(SdkEvent.SDK_UPDATE, metadata -> { + order.add("first"); + threads.add(Thread.currentThread().getName()); + EventThreads.record(); + }); + client.on(SdkEvent.SDK_UPDATE, metadata -> { + order.add("throws"); + throw new IllegalStateException("listener failure under test"); + }); + client.on(SdkEvent.SDK_UPDATE, metadata -> { + order.add("third"); + threads.add(Thread.currentThread().getName()); + EventThreads.record(); + }); + client.blockUntilReady(); + + publishFlagChange(flagsBody(INITIAL_CN, UPDATED_CN, flag("f1", UPDATED_CN, true), -1, -1, "")); + + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> order.size() == 3); + assertEquals(Arrays.asList("first", "throws", "third"), order); + assertEquals("listeners share the single delivery thread", threads.get(0), threads.get(1)); + assertFalse("not delivered on the test thread", threads.get(0).equals(Thread.currentThread().getName())); + } + + // Scenario: the metadata is exposed to handlers. + @Test + public void metadataIsExposedToHandlers() throws Exception { + SplitClient client = buildClient(10000); + Recorder ready = new Recorder(); + Recorder updates = new Recorder(); + client.on(SdkEvent.SDK_READY, ready); + client.on(SdkEvent.SDK_UPDATE, updates); + client.blockUntilReady(); + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> ready.size() == 1); + assertTrue(ready.get(0).isInitialCacheLoad()); + + publishFlagChange(flagsBody(INITIAL_CN, UPDATED_CN, flag("f1", UPDATED_CN, true), -1, -1, "")); + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> updates.size() == 1); + + assertEquals(SdkEventType.FLAGS_UPDATE, updates.get(0).getType()); + assertEquals(Collections.singleton("f1"), updates.get(0).getNames()); + } + + // Scenario: destroy stops events and clears the handlers. + @Test + public void destroyStopsEventsAndClearsHandlers() throws Exception { + SplitClient client = buildClient(10000); + Recorder updates = new Recorder(); + client.on(SdkEvent.SDK_UPDATE, updates); + client.blockUntilReady(); + + factory.destroy(); + + // events published after destroy never reach the dropped handlers + publishFlagChange(flagsBody(INITIAL_CN, UPDATED_CN, flag("f1", UPDATED_CN, true), -1, -1, "")); + Recorder afterDestroy = new Recorder(); + client.on(SdkEvent.SDK_UPDATE, afterDestroy); + client.on(SdkEvent.SDK_READY, afterDestroy); + client.off(SdkEvent.SDK_UPDATE); + await().during(QUIET_WINDOW).atMost(WAIT_SECONDS, TimeUnit.SECONDS) + .until(() -> updates.size() == 0 && afterDestroy.size() == 0); + assertTrue(factory.isDestroyed()); + } + + // Appendix 4, case D: a polled response carrying flags and rule-based segments raises exactly one SDK_UPDATE, + // typed FLAGS_UPDATE. + @Test + public void flagsAndRuleBasedSegmentsInOneResponseRaiseExactlyOneFlagsUpdate() throws Exception { + SplitClient client = buildClient(10000); + Recorder updates = new Recorder(); + client.on(SdkEvent.SDK_UPDATE, updates); + client.blockUntilReady(); + + publishFlagChange(flagsBody(INITIAL_CN, UPDATED_CN, flag("f2", UPDATED_CN, false), + -1, UPDATED_CN, ruleBasedSegment("rbs1", UPDATED_CN))); + + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> updates.size() >= 1); + await().during(QUIET_WINDOW).atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> updates.size() == 1); + assertEquals(SdkEventType.FLAGS_UPDATE, updates.get(0).getType()); + assertEquals(Collections.singleton("f2"), updates.get(0).getNames()); + } + + // Appendix 4, case E: a polled segment change raises exactly one SDK_UPDATE, typed SEGMENTS_UPDATE. + @Test + public void aSegmentChangeRaisesExactlyOneSegmentsUpdate() throws Exception { + SplitClient client = buildClient(10000); + Recorder updates = new Recorder(); + client.on(SdkEvent.SDK_UPDATE, updates); + client.blockUntilReady(); + + backend.scriptSegment(SEGMENT_CN, segmentChange(SEGMENT_CN, SEGMENT_UPDATED_CN, "b", "")); + if (streaming) { + pushSse("xxxx_xxxx_segments", "{\\\"type\\\":\\\"SEGMENT_UPDATE\\\",\\\"changeNumber\\\":" + SEGMENT_UPDATED_CN + + ",\\\"segmentName\\\":\\\"" + SEGMENT + "\\\"}"); + } + + await().atMost(SEGMENT_POLL_WAIT_SECONDS, TimeUnit.SECONDS).until(() -> updates.size() >= 1); + await().during(QUIET_WINDOW).atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> updates.size() == 1); + assertEquals(SdkEventType.SEGMENTS_UPDATE, updates.get(0).getType()); + assertTrue(updates.get(0).getNames().isEmpty()); + } + + // A throwing listener does not affect other listeners or destabilise the SDK. + @Test + public void aThrowingListenerDoesNotDestabiliseTheSdk() throws Exception { + SplitClient client = buildClient(10000); + Recorder healthyReady = new Recorder(); + Recorder healthyUpdate = new Recorder(); + client.on(SdkEvent.SDK_READY, metadata -> { + throw new IllegalStateException("ready listener failure under test"); + }); + client.on(SdkEvent.SDK_READY, healthyReady); + client.on(SdkEvent.SDK_UPDATE, metadata -> { + throw new IllegalStateException("update listener failure under test"); + }); + client.on(SdkEvent.SDK_UPDATE, healthyUpdate); + + client.blockUntilReady(); + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> healthyReady.size() == 1); + assertEquals("on", client.getTreatment("a", "f1")); + + publishFlagChange(flagsBody(INITIAL_CN, UPDATED_CN, flag("f1", UPDATED_CN, true), -1, -1, "")); + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> healthyUpdate.size() == 1); + + // delivery keeps working for subsequent events, and evaluation is unaffected + publishFlagChange(UPDATED_CN, 300L, flagsBody(UPDATED_CN, 300L, flag("f1", 300L, true), -1, -1, "")); + await().atMost(WAIT_SECONDS, TimeUnit.SECONDS).until(() -> healthyUpdate.size() == 2); + assertEquals("on", client.getTreatment("a", "f1")); + } + + private void newBackend(long headersDelayMillis) throws Exception { + backend = new LifecycleScriptDispatcher( + flagsBody(-1, INITIAL_CN, flag("f1", INITIAL_CN, true), -1, -1, ""), + segmentChange(-1, SEGMENT_CN, "a", ""), + headersDelayMillis); + splitServer = new SplitMockServer(backend); + splitServer.start(); + if (streaming) { + sseQueue = new SSEMockServer.SseEventQueue(); + sseServer = new SSEMockServer(sseQueue, (token, version, channel) -> { + if (!"1.1".equals(version)) { + return new Pair<>(new OutboundEvent.Builder().data("wrong version").build(), false); + } + return new Pair<>(null, true); + }); + sseServer.start(); + } + } + + private void stopBackend() throws Exception { + if (sseServer != null) { + sseServer.stop(); + sseServer = null; + } + splitServer.stop(); + } + + private SplitClient buildClient(int blockUntilReadyTimeoutMillis) throws Exception { + SplitClientConfig.Builder builder = SplitClientConfig.builder() + .setBlockUntilReadyTimeout(blockUntilReadyTimeoutMillis) + .endpoint(splitServer.getUrl(), splitServer.getUrl()) + .authServiceURL(String.format("%s/api/auth/enabled", splitServer.getUrl())) + .featuresRefreshRate(5) + .segmentsRefreshRate(30) + .streamingEnabled(streaming); + if (streaming) { + builder.streamingServiceURL("http://localhost:" + sseServer.getPort()); + } + factory = SplitFactoryBuilder.build("fake-api-token", builder.build()); + return factory.client(); + } + + private void publishFlagChange(String body) throws Exception { + publishFlagChange(INITIAL_CN, UPDATED_CN, body); + } + + /** Makes {@code body} the answer to "changes since {@code since}" and, when streaming, announces it. */ + private void publishFlagChange(long since, long till, String body) throws Exception { + backend.scriptFlags(since, body); + if (streaming) { + pushSse("xxxx_xxxx_splits", "{\\\"type\\\":\\\"SPLIT_UPDATE\\\",\\\"changeNumber\\\":" + till + "}"); + } + } + + private void pushSse(String channel, String innerJson) { + sseQueue.push(new OutboundEvent.Builder() + .name("message") + .data("{\"id\":\"22\",\"clientId\":\"22\",\"timestamp\":1592590436082,\"encoding\":\"json\",\"channel\":\"" + + channel + "\",\"data\":\"" + innerJson + "\"}") + .build()); + } + + /** Collects every invocation; a replayed SDK_READY / SDK_READY_TIMED_OUT records {@code null}. */ + public static final class Recorder implements io.split.client.api.SdkEventListener { + private final List received = new CopyOnWriteArrayList<>(); + + @Override + public void handle(SdkEventMetadata metadata) { + EventThreads.record(); + received.add(metadata); + } + + public int size() { + return received.size(); + } + + public SdkEventMetadata get(int index) { + return received.get(index); + } + } +} diff --git a/client/src/test/java/io/split/engine/common/SyncManagerReadyEventsTest.java b/client/src/test/java/io/split/engine/common/SyncManagerReadyEventsTest.java new file mode 100644 index 00000000..afa52244 --- /dev/null +++ b/client/src/test/java/io/split/engine/common/SyncManagerReadyEventsTest.java @@ -0,0 +1,103 @@ +package io.split.engine.common; + +import io.split.client.SplitClientConfig; +import io.split.client.api.SdkEventMetadata; +import io.split.client.events.EventsTask; +import io.split.client.impressions.ImpressionsManagerImpl; +import io.split.client.impressions.UniqueKeysTracker; +import io.split.client.lifecycle.SdkEventsNotifier; +import io.split.client.lifecycle.SdkInternalEvent; +import io.split.engine.SDKReadinessGates; +import io.split.engine.experiments.SplitSynchronizationTask; +import io.split.engine.segments.SegmentSynchronizationTaskImp; +import io.split.telemetry.storage.TelemetryStorage; +import io.split.telemetry.synchronizer.TelemetrySyncTask; +import io.split.telemetry.synchronizer.TelemetrySynchronizer; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.util.concurrent.LinkedBlockingQueue; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; + +public class SyncManagerReadyEventsTest { + + private static void assertReadyMetadata(SdkEventsNotifier notifier) { + ArgumentCaptor metadata = ArgumentCaptor.forClass(SdkEventMetadata.class); + verify(notifier, timeout(2000).times(1)).notify(eq(SdkInternalEvent.SDK_READY), metadata.capture()); + assertTrue(metadata.getValue().isInitialCacheLoad()); + assertNull(metadata.getValue().getLastUpdateTimestamp()); + } + + @Test + public void standaloneSyncManagerNotifiesSdkReadyOnceAfterSyncAll() throws Exception { + Synchronizer synchronizer = mock(Synchronizer.class); + when(synchronizer.syncAll()).thenReturn(true); + SDKReadinessGates gates = new SDKReadinessGates(); + SdkEventsNotifier notifier = mock(SdkEventsNotifier.class); + SplitTasks tasks = SplitTasks.build(mock(SplitSynchronizationTask.class), mock(SegmentSynchronizationTaskImp.class), + mock(ImpressionsManagerImpl.class), mock(EventsTask.class), mock(TelemetrySyncTask.class), + mock(UniqueKeysTracker.class)); + SyncManagerImp syncManager = new SyncManagerImp(tasks, false, synchronizer, mock(PushManager.class), + new LinkedBlockingQueue<>(), gates, mock(TelemetryStorage.class), mock(TelemetrySynchronizer.class), + SplitClientConfig.builder().build(), mock(SplitAPI.class), notifier); + + syncManager.start(); + + assertReadyMetadata(notifier); + assertTrue(gates.isSDKReady()); + syncManager.shutdown(); + } + + @Test + public void standaloneSyncManagerDoesNotNotifyWhenSyncAllNeverSucceeds() throws Exception { + Synchronizer synchronizer = mock(Synchronizer.class); + when(synchronizer.syncAll()).thenReturn(false); + SdkEventsNotifier notifier = mock(SdkEventsNotifier.class); + SplitTasks tasks = SplitTasks.build(mock(SplitSynchronizationTask.class), mock(SegmentSynchronizationTaskImp.class), + mock(ImpressionsManagerImpl.class), mock(EventsTask.class), mock(TelemetrySyncTask.class), + mock(UniqueKeysTracker.class)); + SyncManagerImp syncManager = new SyncManagerImp(tasks, false, synchronizer, mock(PushManager.class), + new LinkedBlockingQueue<>(), new SDKReadinessGates(), mock(TelemetryStorage.class), + mock(TelemetrySynchronizer.class), SplitClientConfig.builder().build(), mock(SplitAPI.class), notifier); + + syncManager.start(); + Thread.sleep(300); + + verify(notifier, never()).notify(any(SdkInternalEvent.class), any(SdkEventMetadata.class)); + syncManager.shutdown(); + } + + @Test + public void localhostSyncManagerNotifiesSdkReadyAfterGate() { + Synchronizer synchronizer = mock(Synchronizer.class); + when(synchronizer.syncAll()).thenReturn(true); + SDKReadinessGates gates = new SDKReadinessGates(); + SdkEventsNotifier notifier = mock(SdkEventsNotifier.class); + + new LocalhostSyncManager(synchronizer, gates, notifier).start(); + + assertTrue(gates.isSDKReady()); + assertReadyMetadata(notifier); + } + + @Test + public void localhostSyncManagerDoesNotNotifyWhenSyncFails() { + Synchronizer synchronizer = mock(Synchronizer.class); + when(synchronizer.syncAll()).thenReturn(false); + SdkEventsNotifier notifier = mock(SdkEventsNotifier.class); + + new LocalhostSyncManager(synchronizer, new SDKReadinessGates(), notifier).start(); + + verifyZeroInteractions(notifier); + } +} diff --git a/client/src/test/java/io/split/engine/common/SynchronizerImpKillEventsTest.java b/client/src/test/java/io/split/engine/common/SynchronizerImpKillEventsTest.java new file mode 100644 index 00000000..92990acd --- /dev/null +++ b/client/src/test/java/io/split/engine/common/SynchronizerImpKillEventsTest.java @@ -0,0 +1,77 @@ +package io.split.engine.common; + +import io.split.client.api.SdkEventType; +import io.split.client.events.EventsTask; +import io.split.client.impressions.ImpressionsManager; +import io.split.client.impressions.UniqueKeysTracker; +import io.split.client.api.SdkEventMetadata; +import io.split.client.lifecycle.SdkEventsNotifier; +import io.split.client.lifecycle.SdkInternalEvent; +import io.split.engine.experiments.FetchResult; +import io.split.engine.experiments.SplitFetcher; +import io.split.engine.experiments.SplitSynchronizationTask; +import io.split.engine.segments.SegmentSynchronizationTask; +import io.split.engine.sse.dtos.SplitKillNotification; +import io.split.storages.RuleBasedSegmentCache; +import io.split.storages.SegmentCache; +import io.split.storages.SplitCache; +import io.split.telemetry.synchronizer.TelemetrySyncTask; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.util.Collections; +import java.util.HashSet; + +import static org.junit.Assert.assertEquals; + +public class SynchronizerImpKillEventsTest { + + private SplitCache _splitCache; + private SdkEventsNotifier _notifier; + private SynchronizerImp _synchronizer; + + @Before + public void setUp() { + SplitFetcher splitFetcher = Mockito.mock(SplitFetcher.class); + Mockito.when(splitFetcher.forceRefresh(Mockito.any(FetchOptions.class))) + .thenReturn(new FetchResult(true, false, new HashSet())); + _splitCache = Mockito.mock(SplitCache.class); + // first read is localKillSplit's staleness check; afterwards the kill has "landed" so refreshSplits returns immediately + Mockito.when(_splitCache.getChangeNumber()).thenReturn(10L, 20L); + RuleBasedSegmentCache rbsCache = Mockito.mock(RuleBasedSegmentCache.class); + SplitTasks tasks = SplitTasks.build(Mockito.mock(SplitSynchronizationTask.class), + Mockito.mock(SegmentSynchronizationTask.class), Mockito.mock(ImpressionsManager.class), + Mockito.mock(EventsTask.class), Mockito.mock(TelemetrySyncTask.class), Mockito.mock(UniqueKeysTracker.class)); + _notifier = Mockito.mock(SdkEventsNotifier.class); + _synchronizer = new SynchronizerImp(tasks, splitFetcher, _splitCache, Mockito.mock(SegmentCache.class), rbsCache, + 50, 10, 5, new HashSet(), _notifier); + } + + @Test + public void killEmitsFlagKilledNotificationWithFlagName() { + _synchronizer.localKillSplit(kill("flag1", 20L)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SdkEventMetadata.class); + Mockito.verify(_notifier).notify(Mockito.eq(SdkInternalEvent.FLAG_KILLED_NOTIFICATION), captor.capture()); + Mockito.verifyNoMoreInteractions(_notifier); + assertEquals(SdkEventType.FLAGS_UPDATE, captor.getValue().getType()); + assertEquals(Collections.singleton("flag1"), captor.getValue().getNames()); + } + + @Test + public void staleKillEmitsNothing() { + _synchronizer.localKillSplit(kill("flag1", 5L)); + + Mockito.verifyZeroInteractions(_notifier); + } + + private static SplitKillNotification kill(String name, long changeNumber) { + SplitKillNotification notification = Mockito.mock(SplitKillNotification.class); + Mockito.when(notification.getSplitName()).thenReturn(name); + Mockito.when(notification.getChangeNumber()).thenReturn(changeNumber); + Mockito.when(notification.getDefaultTreatment()).thenReturn("off"); + return notification; + } +} diff --git a/client/src/test/java/io/split/engine/experiments/SplitFetcherImpEventsTest.java b/client/src/test/java/io/split/engine/experiments/SplitFetcherImpEventsTest.java new file mode 100644 index 00000000..2ad95840 --- /dev/null +++ b/client/src/test/java/io/split/engine/experiments/SplitFetcherImpEventsTest.java @@ -0,0 +1,174 @@ +package io.split.engine.experiments; + +import io.split.client.api.SdkEventType; +import io.split.client.dtos.ChangeDto; +import io.split.client.dtos.RuleBasedSegment; +import io.split.client.dtos.Split; +import io.split.client.dtos.SplitChange; +import io.split.client.dtos.Status; +import io.split.client.interceptors.FlagSetsFilterImpl; +import io.split.client.api.SdkEventMetadata; +import io.split.client.lifecycle.SdkEventsNotifier; +import io.split.client.lifecycle.SdkInternalEvent; +import io.split.engine.common.FetchOptions; +import io.split.storages.RuleBasedSegmentCacheProducer; +import io.split.storages.SplitCacheProducer; +import io.split.telemetry.storage.TelemetryRuntimeProducer; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class SplitFetcherImpEventsTest { + + private SplitChangeFetcher _changeFetcher; + private SplitCacheProducer _splitCache; + private RuleBasedSegmentCacheProducer _rbsCache; + private SdkEventsNotifier _notifier; + private SplitFetcherImp _fetcher; + + @Before + public void setUp() throws Exception { + _changeFetcher = Mockito.mock(SplitChangeFetcher.class); + _splitCache = Mockito.mock(SplitCacheProducer.class); + _rbsCache = Mockito.mock(RuleBasedSegmentCacheProducer.class); + Mockito.when(_splitCache.getChangeNumber()).thenReturn(-1L); + Mockito.when(_rbsCache.getChangeNumber()).thenReturn(-1L); + _notifier = Mockito.mock(SdkEventsNotifier.class); + _fetcher = new SplitFetcherImp(_changeFetcher, new SplitParser(), _splitCache, + Mockito.mock(TelemetryRuntimeProducer.class), new FlagSetsFilterImpl(new HashSet()), + new RuleBasedSegmentParser(), _rbsCache, _notifier); + } + + @Test + public void flagChangeEmitsFlagsUpdatedWithExactNames() throws Exception { + givenChange(flags(activeFlag("f1"), archivedFlag("f2")), rbs()); + + _fetcher.forceRefresh(new FetchOptions.Builder().build()); + + SdkEventMetadata metadata = verifyEmitted(SdkInternalEvent.FLAGS_UPDATED); + assertEquals(SdkEventType.FLAGS_UPDATE, metadata.getType()); + assertEquals(new HashSet<>(Arrays.asList("f1", "f2")), metadata.getNames()); + } + + @Test + public void rbsOnlyChangeEmitsRuleBasedSegmentsUpdatedWithEmptyNames() throws Exception { + givenChange(flags(), rbs(archivedRbs("r1"))); + + _fetcher.forceRefresh(new FetchOptions.Builder().build()); + + SdkEventMetadata metadata = verifyEmitted(SdkInternalEvent.RULE_BASED_SEGMENTS_UPDATED); + assertEquals(SdkEventType.SEGMENTS_UPDATE, metadata.getType()); + assertTrue(metadata.getNames().isEmpty()); + } + + @Test + public void flagAndRbsChangeEmitsOnlyFlagsUpdated() throws Exception { + givenChange(flags(activeFlag("f1")), rbs(archivedRbs("r1"))); + + _fetcher.forceRefresh(new FetchOptions.Builder().build()); + + SdkEventMetadata metadata = verifyEmitted(SdkInternalEvent.FLAGS_UPDATED); + assertEquals(SdkEventType.FLAGS_UPDATE, metadata.getType()); + assertEquals(Collections.singleton("f1"), metadata.getNames()); + } + + @Test + public void emptyPollEmitsNothing() throws Exception { + givenChange(flags(), rbs()); + + _fetcher.forceRefresh(new FetchOptions.Builder().build()); + + Mockito.verifyZeroInteractions(_notifier); + } + + @Test + public void staleChangeEmitsNothing() throws Exception { + Mockito.when(_splitCache.getChangeNumber()).thenReturn(500L); + givenChange(flags(activeFlag("f1")), rbs()); + + _fetcher.forceRefresh(new FetchOptions.Builder().build()); + + Mockito.verifyZeroInteractions(_notifier); + } + + @Test + public void defaultConstructorStillWorks() throws Exception { + givenChange(flags(activeFlag("f1")), rbs()); + SplitFetcherImp plain = new SplitFetcherImp(_changeFetcher, new SplitParser(), _splitCache, + Mockito.mock(TelemetryRuntimeProducer.class), new FlagSetsFilterImpl(new HashSet()), + new RuleBasedSegmentParser(), _rbsCache); + + plain.forceRefresh(new FetchOptions.Builder().build()); + + Mockito.verifyZeroInteractions(_notifier); + } + + private SdkEventMetadata verifyEmitted(SdkInternalEvent event) { + ArgumentCaptor captor = ArgumentCaptor.forClass(SdkEventMetadata.class); + Mockito.verify(_notifier).notify(Mockito.eq(event), captor.capture()); + Mockito.verifyNoMoreInteractions(_notifier); + return captor.getValue(); + } + + private void givenChange(ChangeDto ff, ChangeDto rbs) throws Exception { + SplitChange change = new SplitChange(); + change.featureFlags = ff; + change.ruleBasedSegments = rbs; + Mockito.when(_changeFetcher.fetch(Mockito.anyLong(), Mockito.anyLong(), Mockito.any(FetchOptions.class))) + .thenReturn(change); + } + + private static ChangeDto flags(Split... splits) { + ChangeDto dto = new ChangeDto<>(); + dto.s = -1; + dto.t = splits.length == 0 ? -1 : 100; + dto.d = new ArrayList<>(Arrays.asList(splits)); + return dto; + } + + private static ChangeDto rbs(RuleBasedSegment... segments) { + ChangeDto dto = new ChangeDto<>(); + dto.s = -1; + dto.t = segments.length == 0 ? -1 : 100; + dto.d = new ArrayList<>(Arrays.asList(segments)); + return dto; + } + + private static Split activeFlag(String name) { + Split split = new Split(); + split.name = name; + split.status = Status.ACTIVE; + split.defaultTreatment = "off"; + split.trafficTypeName = "user"; + split.trafficAllocation = 100; + split.trafficAllocationSeed = 1; + split.algo = 2; + split.conditions = new ArrayList<>(); + split.prerequisites = new ArrayList<>(); + return split; + } + + private static Split archivedFlag(String name) { + Split split = new Split(); + split.name = name; + split.status = Status.ARCHIVED; + return split; + } + + private static RuleBasedSegment archivedRbs(String name) { + RuleBasedSegment segment = new RuleBasedSegment(); + segment.name = name; + segment.status = Status.ARCHIVED; + return segment; + } +} diff --git a/client/src/test/java/io/split/engine/segments/SegmentFetcherImpEventsTest.java b/client/src/test/java/io/split/engine/segments/SegmentFetcherImpEventsTest.java new file mode 100644 index 00000000..b05284fa --- /dev/null +++ b/client/src/test/java/io/split/engine/segments/SegmentFetcherImpEventsTest.java @@ -0,0 +1,74 @@ +package io.split.engine.segments; + +import io.split.client.api.SdkEventType; +import io.split.client.dtos.SegmentChange; +import io.split.client.api.SdkEventMetadata; +import io.split.client.lifecycle.SdkEventsNotifier; +import io.split.client.lifecycle.SdkInternalEvent; +import io.split.engine.common.FetchOptions; +import io.split.storages.memory.SegmentCacheInMemoryImpl; +import io.split.telemetry.storage.TelemetryRuntimeProducer; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Arrays; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class SegmentFetcherImpEventsTest { + + private SegmentChangeFetcher _changeFetcher; + private SdkEventsNotifier _notifier; + private SegmentFetcherImp _fetcher; + + @Before + public void setUp() { + _changeFetcher = Mockito.mock(SegmentChangeFetcher.class); + _notifier = Mockito.mock(SdkEventsNotifier.class); + _fetcher = new SegmentFetcherImp("seg", _changeFetcher, new SegmentCacheInMemoryImpl(), + Mockito.mock(TelemetryRuntimeProducer.class), _notifier); + } + + @Test + public void constructionAloneEmitsNothing() { + Mockito.verifyZeroInteractions(_notifier); + } + + @Test + public void membershipChangeEmitsSegmentsUpdatedWithEmptyNames() { + Mockito.when(_changeFetcher.fetch(Mockito.eq("seg"), Mockito.anyLong(), Mockito.any(FetchOptions.class))) + .thenReturn(change(-1, 10, Arrays.asList("a", "b"), new ArrayList())); + + _fetcher.fetch(new FetchOptions.Builder().build()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SdkEventMetadata.class); + Mockito.verify(_notifier).notify(Mockito.eq(SdkInternalEvent.SEGMENTS_UPDATED), captor.capture()); + Mockito.verifyNoMoreInteractions(_notifier); + assertEquals(SdkEventType.SEGMENTS_UPDATE, captor.getValue().getType()); + assertTrue(captor.getValue().getNames().isEmpty()); + } + + @Test + public void noChangeEmitsNothing() { + Mockito.when(_changeFetcher.fetch(Mockito.eq("seg"), Mockito.anyLong(), Mockito.any(FetchOptions.class))) + .thenReturn(change(-1, 10, new ArrayList(), new ArrayList())); + + _fetcher.fetch(new FetchOptions.Builder().build()); + + Mockito.verifyZeroInteractions(_notifier); + } + + private static SegmentChange change(long since, long till, java.util.List added, java.util.List removed) { + SegmentChange change = new SegmentChange(); + change.name = "seg"; + change.since = since; + change.till = till; + change.added = added; + change.removed = removed; + return change; + } +} diff --git a/client/src/test/java/io/split/engine/sse/workers/FeatureFlagWorkerImpEventsTest.java b/client/src/test/java/io/split/engine/sse/workers/FeatureFlagWorkerImpEventsTest.java new file mode 100644 index 00000000..5d214ff4 --- /dev/null +++ b/client/src/test/java/io/split/engine/sse/workers/FeatureFlagWorkerImpEventsTest.java @@ -0,0 +1,114 @@ +package io.split.engine.sse.workers; + +import io.split.client.api.SdkEventMetadata; +import io.split.client.api.SdkEventType; +import io.split.client.dtos.RuleBasedSegment; +import io.split.client.dtos.Split; +import io.split.client.interceptors.FlagSetsFilter; +import io.split.client.interceptors.FlagSetsFilterImpl; +import io.split.client.lifecycle.SdkEventsNotifier; +import io.split.client.lifecycle.SdkInternalEvent; +import io.split.client.utils.Json; +import io.split.engine.common.Synchronizer; +import io.split.engine.common.SynchronizerImp; +import io.split.engine.experiments.RuleBasedSegmentParser; +import io.split.engine.experiments.SplitParser; +import io.split.engine.sse.dtos.CommonChangeNotification; +import io.split.engine.sse.dtos.GenericNotificationData; +import io.split.engine.sse.dtos.IncomingNotification; +import io.split.engine.sse.dtos.RawMessageNotification; +import io.split.storages.RuleBasedSegmentCache; +import io.split.storages.SplitCacheProducer; +import io.split.telemetry.storage.InMemoryTelemetryStorage; +import org.junit.Test; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class FeatureFlagWorkerImpEventsTest { + + private static final FlagSetsFilter FLAG_SETS_FILTER = new FlagSetsFilterImpl(new HashSet<>()); + + private static final String WITH_DEFINITION = "{\"id\":\"vQQ61wzBRO:0:0\",\"clientId\":\"pri:MTUxNzg3MDg1OQ==\",\"timestamp\":1684265694676,\"encoding\":\"json\",\"channel\":\"NzM2MDI5Mzc0_MjkyNTIzNjczMw==_splits\",\"data\":\"{\\\"type\\\":\\\"SPLIT_UPDATE\\\",\\\"changeNumber\\\":1684265694505,\\\"pcn\\\":0,\\\"c\\\":2,\\\"d\\\":\\\"eJzMk99u2kwQxV8lOtdryQZj8N6hD5QPlThSTVNVEUKDPYZt1jZar1OlyO9emf8lVFWv2ss5zJyd82O8hTWUZSqZvW04opwhUVdsIKBSSKR+10vS1HWW7pIdz2NyBjRwHS8IXEopTLgbQqDYT+ZUm3LxlV4J4mg81LpMyKqygPRc94YeM6eQTtjphp4fegLVXvD6Qdjt9wPXF6gs2bqCxPC/2eRpDIEXpXXblpGuWCDljGptZ4bJ5lxYSJRZBoFkTcWKozpfsoH0goHfCXpB6PfcngDpVQnZEUjKIlOr2uwWqiC3zU5L1aF+3p7LFhUkPv8/mY2nk3gGgZxssmZzb8p6A9n25ktVtA9iGI3ODXunQ3HDp+AVWT6F+rZWlrWq7MN+YkSWWvuTDvkMSnNV7J6oTdl6qKTEvGnmjcCGjL2IYC/ovPYgUKnvvPtbmrmApiVryLM7p2jE++AfH6fTx09/HvuF32LWnNjStM0Xh3c8ukZcsZlEi3h8/zCObsBpJ0acqYLTmFdtqitK1V6NzrfpdPBbLmVx4uK26e27izpDu/r5yf/16AXun2Cr4u6w591xw7+LfDidLj6Mv8TXwP8xbofv/c7UmtHMmx8BAAD//0fclvU=\\\"}\"}"; + + private static final String WITHOUT_DEFINITION = "{\"id\":\"vQQ61wzBRO:0:0\",\"clientId\":\"pri:MTUxNzg3MDg1OQ==\",\"timestamp\":1684265694676,\"encoding\":\"json\",\"channel\":\"NzM2MDI5Mzc0_MjkyNTIzNjczMw==_splits\",\"data\":\"{\\\"type\\\":\\\"SPLIT_UPDATE\\\",\\\"changeNumber\\\":1684265694505}\"}"; + + private static final String RBS_WITH_DEFINITION = "{\"id\":\"vQQ61wzBRO:0:0\",\"clientId\":\"pri:MTUxNzg3MDg1OQ==\",\"timestamp\":1684265694676,\"encoding\":\"json\",\"channel\":\"NzM2MDI5Mzc0_MjkyNTIzNjczMw==_splits\",\"data\":\"{\\\"type\\\":\\\"RB_SEGMENT_UPDATE\\\",\\\"changeNumber\\\":1684265694505,\\\"pcn\\\":0,\\\"c\\\":0,\\\"d\\\":\\\"eyJjaGFuZ2VOdW1iZXIiOiA1LCAibmFtZSI6ICJzYW1wbGVfcnVsZV9iYXNlZF9zZWdtZW50IiwgInN0YXR1cyI6ICJBQ1RJVkUiLCAidHJhZmZpY1R5cGVOYW1lIjogInVzZXIiLCAiZXhjbHVkZWQiOiB7ImtleXMiOiBbIm1hdXJvQHNwbGl0LmlvIiwgImdhc3RvbkBzcGxpdC5pbyJdLCAic2VnbWVudHMiOiBbXX0sICJjb25kaXRpb25zIjogW3sibWF0Y2hlckdyb3VwIjogeyJjb21iaW5lciI6ICJBTkQiLCAibWF0Y2hlcnMiOiBbeyJrZXlTZWxlY3RvciI6IHsidHJhZmZpY1R5cGUiOiAidXNlciIsICJhdHRyaWJ1dGUiOiAiZW1haWwifSwgIm1hdGNoZXJUeXBlIjogIkVORFNfV0lUSCIsICJuZWdhdGUiOiBmYWxzZSwgIndoaXRlbGlzdE1hdGNoZXJEYXRhIjogeyJ3aGl0ZWxpc3QiOiBbIkBzcGxpdC5pbyJdfX1dfX1dfQ==\\\"}\"}"; + + private static final String RBS_WITHOUT_DEFINITION = "{\"id\":\"vQQ61wzBRO:0:0\",\"clientId\":\"pri:MTUxNzg3MDg1OQ==\",\"timestamp\":1684265694676,\"encoding\":\"json\",\"channel\":\"NzM2MDI5Mzc0_MjkyNTIzNjczMw==_splits\",\"data\":\"{\\\"type\\\":\\\"RB_SEGMENT_UPDATE\\\",\\\"changeNumber\\\":1684265694505}\"}"; + + private static final class Recorded { + final SdkInternalEvent event; + final SdkEventMetadata metadata; + + Recorded(SdkInternalEvent event, SdkEventMetadata metadata) { + this.event = event; + this.metadata = metadata; + } + } + + private static List refresh(String notification) { + return refresh(notification, 0L, Split.class); + } + + private static List refreshRuleBasedSegment(String notification, long cachedChangeNumber) { + return refresh(notification, cachedChangeNumber, RuleBasedSegment.class); + } + + private static List refresh(String notification, long rbsCachedChangeNumber, Class definitionType) { + List recorded = new ArrayList<>(); + SdkEventsNotifier notifier = (event, metadata) -> recorded.add(new Recorded(event, metadata)); + Synchronizer synchronizer = Mockito.mock(SynchronizerImp.class); + SplitCacheProducer splitCacheProducer = Mockito.mock(SplitCacheProducer.class); + RuleBasedSegmentCache ruleBasedSegmentCache = Mockito.mock(RuleBasedSegmentCache.class); + Mockito.when(ruleBasedSegmentCache.getChangeNumber()).thenReturn(rbsCachedChangeNumber); + FeatureFlagWorkerImp worker = new FeatureFlagWorkerImp(synchronizer, new SplitParser(), new RuleBasedSegmentParser(), + splitCacheProducer, ruleBasedSegmentCache, new InMemoryTelemetryStorage(), FLAG_SETS_FILTER, notifier); + + RawMessageNotification raw = Json.fromJson(notification, RawMessageNotification.class); + GenericNotificationData data = Json.fromJson(raw.getData(), GenericNotificationData.class); + worker.executeRefresh((IncomingNotification) new CommonChangeNotification(data, definitionType)); + return recorded; + } + + @Test + public void aFlagDefinitionPushedOverSseRaisesOneFlagsUpdated() { + List recorded = refresh(WITH_DEFINITION); + + assertEquals(1, recorded.size()); + assertEquals(SdkInternalEvent.FLAGS_UPDATED, recorded.get(0).event); + assertEquals(SdkEventType.FLAGS_UPDATE, recorded.get(0).metadata.getType()); + assertEquals(1, recorded.get(0).metadata.getNames().size()); + } + + @Test + public void aNotificationWithoutDefinitionRaisesNothingItself() { + // The worker falls back to refreshSplits(); the fetcher is then the one that emits. + assertTrue(refresh(WITHOUT_DEFINITION).isEmpty()); + } + + @Test + public void aRuleBasedSegmentDefinitionPushedOverSseRaisesOneRuleBasedSegmentsUpdated() { + List recorded = refreshRuleBasedSegment(RBS_WITH_DEFINITION, 0L); + + assertEquals(1, recorded.size()); + assertEquals(SdkInternalEvent.RULE_BASED_SEGMENTS_UPDATED, recorded.get(0).event); + assertEquals(SdkEventType.SEGMENTS_UPDATE, recorded.get(0).metadata.getType()); + assertTrue(recorded.get(0).metadata.getNames().isEmpty()); + } + + @Test + public void aRuleBasedSegmentNotificationWithoutDefinitionRaisesNothingItself() { + assertTrue(refreshRuleBasedSegment(RBS_WITHOUT_DEFINITION, 0L).isEmpty()); + } + + @Test + public void aStaleRuleBasedSegmentNotificationRaisesNothing() { + assertTrue(refreshRuleBasedSegment(RBS_WITH_DEFINITION, Long.MAX_VALUE).isEmpty()); + } +} diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 00000000..67bcc2f7 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,2 @@ +.gradle/ +build/ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..b1606c68 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,14 @@ +# e2e + +Mimics a real consumer of java-client. It builds the SDK, installs it to mavenLocal, then consumes it from there +in a separate Gradle build: the `non-shaded` variant (`test`) and the fat jar (`fatJarTest`). Nothing in the +module provides `io.harness:events` or the other libraries the artifacts are meant to bundle. + +Each variant has three tests: it initialises the SDK in localhost mode, checks the jar's contents (bundled and +relocated libraries), and runs `jdeps` to fail on any referenced class that can't be resolved. + +``` +cd e2e && ./gradlew test fatJarTest +``` + +Requires Maven and JDK 17 on the path. The SDK version is read from the parent `pom.xml`. diff --git a/e2e/build.gradle b/e2e/build.gradle new file mode 100644 index 00000000..cf415b31 --- /dev/null +++ b/e2e/build.gradle @@ -0,0 +1,99 @@ +// Mimics a real consumer of java-client: resolves it from mavenLocal, once with the +// `non-shaded` classifier and once as the fat jar. It builds and installs java-client first, so the test +// always runs against the current source. Nothing here provides io.harness:events: a real +// consumer only gets what the published pom declares, so the artifact must bundle it. +plugins { + id 'java' +} + +// Single source of truth: the parent pom's own version. +def sdkVersion = new XmlSlurper().parse(file('../pom.xml')).version.text() +assert sdkVersion : 'could not read the version from ../pom.xml' + +// The local Maven repo, shared explicitly with the `mvn` calls below. In CI, HOME (where the events install lands) +// differs from the JVM's user.home, so mavenLocal() alone would look in a different repo than Maven installed to. +def localRepo = System.getProperty('maven.repo.local') ?: "${System.getenv('HOME') ?: System.getProperty('user.home')}/.m2/repository" + +repositories { + maven { + url = uri(localRepo) + // Only what this build installs; Maven keeps poms without jars for everything else it resolved. + content { includeGroup 'io.split.client' } + } + mavenCentral() +} + +dependencies { + testImplementation("io.split.client:java-client:${sdkVersion}:non-shaded") { + exclude group: 'org.eclipse.jetty' + exclude group: 'javax.servlet', module: 'servlet-api' + // mavenLocal may hold io.harness:events from a local jvm-commons build; never let it mask a missing bundle. + exclude group: 'io.harness' + } + // Required by the non-shaded java-client (a real non-shaded consumer declares these itself). + testImplementation 'com.google.guava:guava:33.4.8-jre' + testImplementation 'org.apache.httpcomponents.client5:httpclient5:5.5' + // Only the localhost YAML file this test reads needs it; the real SDK path does not. + testImplementation 'org.yaml:snakeyaml:2.0' + testImplementation 'com.google.code.gson:gson:2.13.1' + testImplementation 'org.slf4j:slf4j-simple:1.7.36' + testImplementation 'junit:junit:4.13.1' +} + +// The default (fully shaded) artifact has its own source set: the same coordinates resolve with and without the +// `non-shaded` classifier, which one configuration cannot do. +sourceSets { + fatJar // src/fatJar/{java,resources} +} + +dependencies { + // Deliberately nothing but the SDK: the fat jar must carry guava, httpclient5 and snakeyaml itself. + fatJarImplementation "io.split.client:java-client:${sdkVersion}" + fatJarImplementation 'org.slf4j:slf4j-simple:1.7.36' + fatJarImplementation 'junit:junit:4.13.1' +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +// Install the SDK modules into ~/.m2 before the tests resolve them. +tasks.register('installJavaClient', Exec) { + workingDir = file('..') + // Build from clean, then install again without `clean`: an incremental build is where the plain module jar used to + // be reused after shading, turning the non-shaded artifact into the fat jar (see forceCreation in client/pom.xml). + def mvn = "mvn --batch-mode -q install -DskipTests -Dcheckstyle.skip=true -Dmaven.repo.local=${localRepo}" + commandLine 'sh', '-c', "${mvn.replace(' install', ' clean install')} && ${mvn}" + inputs.files(fileTree('../client/src/main'), file('../client/pom.xml'), file('../pom.xml')) + outputs.upToDateWhen { false } +} + +// compileTestJava resolves the artifact, so the install has to come before it, not just before `test`. +tasks.named('compileTestJava') { + dependsOn 'installJavaClient' +} +tasks.named('compileFatJarJava') { + dependsOn 'installJavaClient' +} + +tasks.register('fatJarTest', Test) { + description = 'Runs the e2e tests against the default (fully shaded) java-client artifact.' + group = 'verification' + testClassesDirs = sourceSets.fatJar.output.classesDirs + classpath = sourceSets.fatJar.runtimeClasspath + testLogging { + events 'passed', 'failed' + exceptionFormat 'full' + } +} + +tasks.named('test') { + // `./gradlew test` covers both artifacts; the finalizer runs even when this task fails. + finalizedBy 'fatJarTest' + testLogging { + events 'passed', 'failed' + exceptionFormat 'full' + showStandardStreams = true + } +} diff --git a/e2e/gradle/wrapper/gradle-wrapper.jar b/e2e/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..d64cd491 Binary files /dev/null and b/e2e/gradle/wrapper/gradle-wrapper.jar differ diff --git a/e2e/gradle/wrapper/gradle-wrapper.properties b/e2e/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..1af9e093 --- /dev/null +++ b/e2e/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/e2e/gradlew b/e2e/gradlew new file mode 100755 index 00000000..1aa94a42 --- /dev/null +++ b/e2e/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/e2e/gradlew.bat b/e2e/gradlew.bat new file mode 100644 index 00000000..6689b85b --- /dev/null +++ b/e2e/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/e2e/settings.gradle b/e2e/settings.gradle new file mode 100644 index 00000000..d0cf9b62 --- /dev/null +++ b/e2e/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'java-client-e2e' diff --git a/e2e/src/fatJar/java/io/split/e2e/FatJarConsumerE2ETest.java b/e2e/src/fatJar/java/io/split/e2e/FatJarConsumerE2ETest.java new file mode 100644 index 00000000..a4782d9e --- /dev/null +++ b/e2e/src/fatJar/java/io/split/e2e/FatJarConsumerE2ETest.java @@ -0,0 +1,37 @@ +package io.split.e2e; + +import io.split.client.SplitClient; +import io.split.client.SplitClientConfig; +import io.split.client.SplitFactory; +import io.split.client.SplitFactoryBuilder; +import org.junit.Assert; +import org.junit.Test; + +import java.net.URL; + +/** + * Initialises the SDK from the default (fully shaded) artifact with nothing else on the classpath: guava, httpclient5 + * and snakeyaml must all come from inside the jar, which is what the fat jar promises its consumers. + */ +public class FatJarConsumerE2ETest { + + @Test + public void initialisesAndEvaluatesFromTheFatJarAlone() throws Exception { + URL splits = getClass().getClassLoader().getResource("splits.yaml"); + Assert.assertNotNull("splits.yaml test resource missing", splits); + + SplitClientConfig config = SplitClientConfig.builder() + .splitFile(splits.getPath()) + .setBlockUntilReadyTimeout(10000) + .build(); + + SplitFactory factory = SplitFactoryBuilder.build("localhost", config); + try { + SplitClient client = factory.client(); + client.blockUntilReady(); + Assert.assertEquals("on", client.getTreatment("any_user", "e2e_feature")); + } finally { + factory.destroy(); + } + } +} diff --git a/e2e/src/fatJar/java/io/split/e2e/FatJarShapeE2ETest.java b/e2e/src/fatJar/java/io/split/e2e/FatJarShapeE2ETest.java new file mode 100644 index 00000000..cb1a946a --- /dev/null +++ b/e2e/src/fatJar/java/io/split/e2e/FatJarShapeE2ETest.java @@ -0,0 +1,54 @@ +package io.split.e2e; + +import io.split.client.SplitFactory; +import org.junit.Assert; +import org.junit.Test; + +import java.io.File; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * Pins what the default artifact is: every bundled library present under its relocated name and none left under its + * original one, where it would clash with the customer's own copy. + */ +public class FatJarShapeE2ETest { + + private static final String[] RELOCATED = {"split/com/google/", "split/org/apache/", "split/org/yaml/", + "split/org/checkerframework/", "split/io/harness/events/"}; + private static final String[] MUST_NOT_LEAK = {"com/google/", "org/apache/", "org/yaml/", "org/checkerframework/", + "io/harness/"}; + + @Test + public void bundlesEveryLibraryRelocatedAndLeaksNone() throws Exception { + File jar = new File(SplitFactory.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + Assert.assertTrue("expected the fat jar on the classpath, got " + jar, + jar.getName().startsWith("java-client-") && !jar.getName().contains("non-shaded")); + + boolean[] found = new boolean[RELOCATED.length]; + List leaked = new ArrayList<>(); + try (JarFile jarFile = new JarFile(jar)) { + for (Enumeration entries = jarFile.entries(); entries.hasMoreElements(); ) { + String name = entries.nextElement().getName(); + for (int i = 0; i < RELOCATED.length; i++) { + found[i] |= name.startsWith(RELOCATED[i]); + } + for (String prefix : MUST_NOT_LEAK) { + if (name.startsWith(prefix)) { + leaked.add(name); + break; + } + } + } + } + + for (int i = 0; i < RELOCATED.length; i++) { + Assert.assertTrue(RELOCATED[i] + " is missing from " + jar, found[i]); + } + Assert.assertTrue("unrelocated classes in " + jar + " (" + leaked.size() + " entries, e.g. " + + (leaked.isEmpty() ? "" : leaked.get(0)) + ")", leaked.isEmpty()); + } +} diff --git a/e2e/src/fatJar/java/io/split/e2e/FatJarUnresolvedReferencesE2ETest.java b/e2e/src/fatJar/java/io/split/e2e/FatJarUnresolvedReferencesE2ETest.java new file mode 100644 index 00000000..92bfc0f6 --- /dev/null +++ b/e2e/src/fatJar/java/io/split/e2e/FatJarUnresolvedReferencesE2ETest.java @@ -0,0 +1,49 @@ +package io.split.e2e; + +import io.split.client.SplitFactory; +import org.junit.Assert; +import org.junit.Test; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * The fat jar must be self-contained, so jdeps gets no classpath. + * Runs jdeps over the jar and fails on any referenced class that cannot be resolved, so a package added to the + * SDK but left out of the build's include list is caught even though initialising the SDK would still work. + * Only references from the SDK's own classes count: the bundled libraries carry optional annotation references of + * their own. The optional pluggable-storage module and slf4j (a declared dependency) are the only expected gaps. + */ +public class FatJarUnresolvedReferencesE2ETest { + + @Test + public void everyReferencedClassIsResolvable() throws Exception { + File jar = new File(SplitFactory.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + + List command = new ArrayList<>(); + command.add(new File(System.getProperty("java.home"), "bin/jdeps").getPath()); + command.add("--multi-release"); + command.add("base"); + command.add("-verbose:class"); + + command.add(jar.getPath()); + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + + List unresolved = new ArrayList<>(); + StringBuilder output = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + for (String line; (line = reader.readLine()) != null; ) { + output.append(line).append('\n'); + if (line.endsWith("not found") && line.trim().startsWith("io.split.") && !line.contains("-> pluggable.") && !line.contains("-> org.slf4j.")) { + unresolved.add(line.trim()); + } + } + } + Assert.assertEquals("jdeps failed:\n" + output, 0, process.waitFor()); + Assert.assertTrue("classes referenced but missing from the self-contained fat jar " + jar + ": " + unresolved, unresolved.isEmpty()); + } +} diff --git a/e2e/src/fatJar/resources/splits.yaml b/e2e/src/fatJar/resources/splits.yaml new file mode 100644 index 00000000..cf943790 --- /dev/null +++ b/e2e/src/fatJar/resources/splits.yaml @@ -0,0 +1,2 @@ +- e2e_feature: + treatment: 'on' diff --git a/e2e/src/test/java/io/split/e2e/NonShadedConsumerE2ETest.java b/e2e/src/test/java/io/split/e2e/NonShadedConsumerE2ETest.java new file mode 100644 index 00000000..bcba2bc8 --- /dev/null +++ b/e2e/src/test/java/io/split/e2e/NonShadedConsumerE2ETest.java @@ -0,0 +1,38 @@ +package io.split.e2e; + +import io.split.client.SplitClient; +import io.split.client.SplitClientConfig; +import io.split.client.SplitFactory; +import io.split.client.SplitFactoryBuilder; +import org.junit.Assert; +import org.junit.Test; + +import java.net.URL; + +/** + * Initialises the SDK the way a consumer of the published non-shaded artifact does (see build.gradle). + * Localhost mode needs no network or keys, yet still builds the SDK events manager, so a class the + * artifact does not bundle or declare surfaces here as a NoClassDefFoundError. + */ +public class NonShadedConsumerE2ETest { + + @Test + public void initialisesAndEvaluatesFromTheNonShadedArtifact() throws Exception { + URL splits = getClass().getClassLoader().getResource("splits.yaml"); + Assert.assertNotNull("splits.yaml test resource missing", splits); + + SplitClientConfig config = SplitClientConfig.builder() + .splitFile(splits.getPath()) + .setBlockUntilReadyTimeout(10000) + .build(); + + SplitFactory factory = SplitFactoryBuilder.build("localhost", config); + try { + SplitClient client = factory.client(); + client.blockUntilReady(); + Assert.assertEquals("on", client.getTreatment("any_user", "e2e_feature")); + } finally { + factory.destroy(); + } + } +} diff --git a/e2e/src/test/java/io/split/e2e/NonShadedJarShapeE2ETest.java b/e2e/src/test/java/io/split/e2e/NonShadedJarShapeE2ETest.java new file mode 100644 index 00000000..fec204ae --- /dev/null +++ b/e2e/src/test/java/io/split/e2e/NonShadedJarShapeE2ETest.java @@ -0,0 +1,53 @@ +package io.split.e2e; + +import io.split.client.SplitFactory; +import org.junit.Assert; +import org.junit.Test; + +import java.io.File; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * Pins what the `non-shaded` artifact is: the thin jar plus the relocated io.harness:events, and nothing else + * bundled. Initialising the SDK (NonShadedConsumerE2ETest) cannot tell this apart from the fat jar, which also + * works, so a build that silently produces the fat jar under the non-shaded name would otherwise go unnoticed. + */ +public class NonShadedJarShapeE2ETest { + + private static final String[] BUNDLED_LIBRARIES = {"split/com/google/", "split/org/apache/", "split/org/yaml/", + "split/org/checkerframework/"}; + + @Test + public void bundlesOnlyTheRelocatedEventsLibrary() throws Exception { + File jar = new File(SplitFactory.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + Assert.assertTrue("expected the non-shaded jar on the classpath, got " + jar, jar.getName().endsWith("-non-shaded.jar")); + + boolean hasEventsConfig = false; + List unrelocatedEvents = new ArrayList<>(); + List bundledLibraries = new ArrayList<>(); + try (JarFile jarFile = new JarFile(jar)) { + for (Enumeration entries = jarFile.entries(); entries.hasMoreElements(); ) { + String name = entries.nextElement().getName(); + hasEventsConfig |= name.equals("split/io/harness/events/EventsManagerConfig.class"); + if (name.startsWith("io/harness/")) { + unrelocatedEvents.add(name); + } + for (String library : BUNDLED_LIBRARIES) { + if (name.startsWith(library)) { + bundledLibraries.add(name); + break; + } + } + } + } + + Assert.assertTrue("relocated io.harness.events classes are missing from " + jar, hasEventsConfig); + Assert.assertTrue("unrelocated io.harness classes in " + jar + ": " + unrelocatedEvents, unrelocatedEvents.isEmpty()); + Assert.assertTrue(jar + " bundles libraries that consumers of the non-shaded variant provide themselves (" + bundledLibraries.size() + + " entries, e.g. " + (bundledLibraries.isEmpty() ? "" : bundledLibraries.get(0)) + ")", bundledLibraries.isEmpty()); + } +} diff --git a/e2e/src/test/java/io/split/e2e/NonShadedUnresolvedReferencesE2ETest.java b/e2e/src/test/java/io/split/e2e/NonShadedUnresolvedReferencesE2ETest.java new file mode 100644 index 00000000..476adbb3 --- /dev/null +++ b/e2e/src/test/java/io/split/e2e/NonShadedUnresolvedReferencesE2ETest.java @@ -0,0 +1,49 @@ +package io.split.e2e; + +import io.split.client.SplitFactory; +import org.junit.Assert; +import org.junit.Test; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * The non-shaded jar leaves guava, httpclient5, snakeyaml and gson to the consumer, so they are resolved from the test classpath. + * Runs jdeps over the jar and fails on any referenced class that cannot be resolved, so a package added to the + * SDK but left out of the build's include list is caught even though initialising the SDK would still work. + * The optional pluggable-storage module is the only expected gap. + */ +public class NonShadedUnresolvedReferencesE2ETest { + + @Test + public void everyReferencedClassIsResolvable() throws Exception { + File jar = new File(SplitFactory.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + + List command = new ArrayList<>(); + command.add(new File(System.getProperty("java.home"), "bin/jdeps").getPath()); + command.add("--multi-release"); + command.add("base"); + command.add("-verbose:class"); + command.add("-cp"); + command.add(System.getProperty("java.class.path")); + command.add(jar.getPath()); + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + + List unresolved = new ArrayList<>(); + StringBuilder output = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + for (String line; (line = reader.readLine()) != null; ) { + output.append(line).append('\n'); + if (line.endsWith("not found") && !line.startsWith(jar.getName()) && !line.contains("-> pluggable.")) { + unresolved.add(line.trim()); + } + } + } + Assert.assertEquals("jdeps failed:\n" + output, 0, process.waitFor()); + Assert.assertTrue("classes referenced but missing from the non-shaded jar and from the consumer dependencies, in " + jar + ": " + unresolved, unresolved.isEmpty()); + } +} diff --git a/e2e/src/test/resources/splits.yaml b/e2e/src/test/resources/splits.yaml new file mode 100644 index 00000000..cf943790 --- /dev/null +++ b/e2e/src/test/resources/splits.yaml @@ -0,0 +1,2 @@ +- e2e_feature: + treatment: 'on' diff --git a/jvm-commons b/jvm-commons new file mode 160000 index 00000000..322dcf5e --- /dev/null +++ b/jvm-commons @@ -0,0 +1 @@ +Subproject commit 322dcf5e3b04ec16df9882633c6ef721f4b033a9 diff --git a/okhttp-modules/pom.xml b/okhttp-modules/pom.xml index b4401444..c0dacf46 100644 --- a/okhttp-modules/pom.xml +++ b/okhttp-modules/pom.xml @@ -5,10 +5,10 @@ java-client-parent io.split.client - 4.18.4 + 4.19.0 4.0.0 - 4.18.4 + 4.19.0 okhttp-modules jar http-modules @@ -49,7 +49,7 @@ io.split.client java-client - 4.18.4 + 4.19.0 compile diff --git a/okhttp-modules/src/test/java/io/split/httpmodules/okhttp/SplitFactoryTests.java b/okhttp-modules/src/test/java/io/split/httpmodules/okhttp/SplitFactoryTests.java index 23cf3cb5..c233942b 100644 --- a/okhttp-modules/src/test/java/io/split/httpmodules/okhttp/SplitFactoryTests.java +++ b/okhttp-modules/src/test/java/io/split/httpmodules/okhttp/SplitFactoryTests.java @@ -6,6 +6,7 @@ import org.junit.runner.RunWith; import org.mockito.stubbing.Answer; import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PowerMockIgnore; import org.powermock.core.classloader.annotations.PrepareForTest; import org.powermock.modules.junit4.PowerMockRunner; @@ -22,6 +23,8 @@ @RunWith(PowerMockRunner.class) @PrepareForTest(OkHttpModule.class) +// jdk.net loads a JDK native library, which cannot be loaded again from PowerMock's classloader. +@PowerMockIgnore("jdk.net.*") public class SplitFactoryTests { @Test public void testFactoryCreatingClient() throws Exception { diff --git a/pluggable-storage/pom.xml b/pluggable-storage/pom.xml index 428b90c8..c0d51c33 100644 --- a/pluggable-storage/pom.xml +++ b/pluggable-storage/pom.xml @@ -6,7 +6,7 @@ java-client-parent io.split.client - 4.18.4 + 4.19.0 2.1.0 diff --git a/pom.xml b/pom.xml index 329d79cd..afc7fef6 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.split.client java-client-parent - 4.18.4 + 4.19.0 diff --git a/redis-wrapper/pom.xml b/redis-wrapper/pom.xml index 846f3000..5606b223 100644 --- a/redis-wrapper/pom.xml +++ b/redis-wrapper/pom.xml @@ -6,7 +6,7 @@ java-client-parent io.split.client - 4.18.4 + 4.19.0 redis-wrapper 3.1.2 diff --git a/testing/pom.xml b/testing/pom.xml index 38f97ec9..53e7e88a 100644 --- a/testing/pom.xml +++ b/testing/pom.xml @@ -5,7 +5,7 @@ io.split.client java-client-parent - 4.18.4 + 4.19.0 java-client-testing jar diff --git a/testing/src/main/java/io/split/client/testing/SplitClientForTest.java b/testing/src/main/java/io/split/client/testing/SplitClientForTest.java index 01020950..5b2cc784 100644 --- a/testing/src/main/java/io/split/client/testing/SplitClientForTest.java +++ b/testing/src/main/java/io/split/client/testing/SplitClientForTest.java @@ -3,6 +3,8 @@ import io.split.client.SplitAndKey; import io.split.client.SplitClient; import io.split.client.api.Key; +import io.split.client.api.SdkEvent; +import io.split.client.api.SdkEventListener; import io.split.client.api.SplitResult; import io.split.client.dtos.EvaluationOptions; import io.split.grammar.Treatments; @@ -320,6 +322,21 @@ public void destroy() { } + @Override + public void on(SdkEvent event, Runnable callback) { + // events are not produced by the test client + } + + @Override + public void on(SdkEvent event, SdkEventListener listener) { + // events are not produced by the test client + } + + @Override + public void off(SdkEvent event) { + // events are not produced by the test client + } + @Override public boolean track(String key, String trafficType, String eventType) { return false;