*/
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;