diff --git a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/ListenerRegistry.java b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/ListenerRegistry.java new file mode 100644 index 00000000..232f4654 --- /dev/null +++ b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/ListenerRegistry.java @@ -0,0 +1,112 @@ +package sdktest; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.interfaces.FlagTracker; +import com.launchdarkly.sdk.server.interfaces.FlagValueChangeListener; + +import org.slf4j.Logger; + +import java.net.URI; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import sdktest.Representations.FlagChangeNotification; +import sdktest.Representations.FlagValueChangeNotification; + +/** + * The flag change listeners that the test harness has registered with one SDK client, keyed by + * the listener ID the harness assigned. Each listener posts a notification to its callback URI + * whenever the SDK fires it. Registering an ID that is already in use replaces the earlier + * listener. + */ +final class ListenerRegistry { + private final FlagTracker tracker; + private final Logger logger; + private final Map listeners = new HashMap<>(); + + ListenerRegistry(FlagTracker tracker, Logger logger) { + this.tracker = tracker; + this.logger = logger; + } + + /** + * Registers a general flag change listener. Every flag change event the SDK delivers is posted + * as a notification carrying the flag key. + */ + synchronized void registerFlagChangeListener(String listenerId, URI callbackUri) { + HookCallbackService callback = new HookCallbackService(callbackUri); + FlagChangeListener listener = event -> { + FlagChangeNotification notification = new FlagChangeNotification(); + notification.listenerId = listenerId; + notification.flagKey = event.getKey(); + post(callback, listenerId, notification); + }; + tracker.addFlagChangeListener(listener); + store(listenerId, listener); + } + + /** + * Registers a flag value change listener for one flag and context. The SDK re-evaluates the flag + * on each change and fires only when the value differs; the notification carries both values. + * The Java SDK's API takes no application default value, so the harness's default is accepted + * but not used. + */ + synchronized void registerFlagValueChangeListener(String listenerId, String flagKey, LDContext context, + URI callbackUri) { + HookCallbackService callback = new HookCallbackService(callbackUri); + FlagValueChangeListener valueListener = event -> { + FlagValueChangeNotification notification = new FlagValueChangeNotification(); + notification.listenerId = listenerId; + notification.flagKey = event.getKey(); + notification.oldValue = event.getOldValue(); + notification.newValue = event.getNewValue(); + post(callback, listenerId, notification); + }; + FlagChangeListener subscription = tracker.addFlagValueChangeListener(flagKey, context, valueListener); + store(listenerId, subscription); + } + + /** + * Removes a listener so that it posts no further notifications. + * + * @return false if no listener has that ID + */ + synchronized boolean unregister(String listenerId) { + FlagChangeListener listener = listeners.remove(listenerId); + if (listener == null) { + return false; + } + tracker.removeFlagChangeListener(listener); + return true; + } + + /** + * Removes every listener. Called when the SDK client entity closes. + */ + synchronized void closeAll() { + for (FlagChangeListener listener : new ArrayList<>(listeners.values())) { + tracker.removeFlagChangeListener(listener); + } + listeners.clear(); + } + + private void store(String listenerId, FlagChangeListener listener) { + FlagChangeListener previous = listeners.put(listenerId, listener); + if (previous != null) { + tracker.removeFlagChangeListener(previous); + } + } + + // The SDK calls listeners from a worker thread. A callback failure must not propagate into the + // SDK's dispatch, so it is logged instead. + private void post(HookCallbackService callback, String listenerId, Object notification) { + try { + callback.post(notification); + } catch (RuntimeException e) { + logger.warn("Listener {} could not post its notification: {}", listenerId, e.toString()); + } + } +} diff --git a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/Representations.java b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/Representations.java index cd7593e7..9b3253ee 100644 --- a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/Representations.java +++ b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/Representations.java @@ -215,6 +215,41 @@ public static class CommandParams { MigrationVariationParams migrationVariation; MigrationOperationParams migrationOperation; + + RegisterFlagChangeListenerParams registerFlagChangeListener; + RegisterFlagValueChangeListenerParams registerFlagValueChangeListener; + UnregisterListenerParams unregisterListener; + } + + public static class RegisterFlagChangeListenerParams { + String listenerId; + URI callbackUri; + } + + public static class RegisterFlagValueChangeListenerParams { + String listenerId; + String flagKey; + LDContext context; + LDValue defaultValue; + URI callbackUri; + } + + public static class UnregisterListenerParams { + String listenerId; + } + + // Posted to a listener's callback URI when a general flag change listener fires. + public static class FlagChangeNotification { + String listenerId; + String flagKey; + } + + // Posted to a listener's callback URI when a flag value change listener fires. + public static class FlagValueChangeNotification { + String listenerId; + String flagKey; + LDValue oldValue; + LDValue newValue; } public static class EvaluateFlagParams { diff --git a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java index f6bb9d96..d1bf7c0d 100644 --- a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java +++ b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/SdkClientEntity.java @@ -63,6 +63,9 @@ import sdktest.Representations.EvaluateFlagResponse; import sdktest.Representations.GetBigSegmentsStoreStatusResponse; import sdktest.Representations.IdentifyEventParams; +import sdktest.Representations.RegisterFlagChangeListenerParams; +import sdktest.Representations.RegisterFlagValueChangeListenerParams; +import sdktest.Representations.UnregisterListenerParams; import sdktest.Representations.HookConfig; import sdktest.Representations.SdkConfigHookParams; import sdktest.Representations.SdkConfigParams; @@ -76,6 +79,7 @@ public class SdkClientEntity { private final LDClient client; + private final ListenerRegistry listeners; final Logger logger; public SdkClientEntity(TestService owner, CreateInstanceParams params) { @@ -84,6 +88,7 @@ public SdkClientEntity(TestService owner, CreateInstanceParams params) { LDConfig config = buildSdkConfig(params.configuration, params.tag); this.client = new LDClient(params.configuration.credential, config); + this.listeners = new ListenerRegistry(client.getFlagTracker(), logger); if (!client.isInitialized() && !params.configuration.initCanFail) { throw new RuntimeException("client initialization failed or timed out"); } @@ -129,6 +134,20 @@ public Object doCommand(CommandParams params) throws TestService.BadRequestExcep return res; case "migrationOperation": return doMigrationOperation(params.migrationOperation); + case "registerFlagChangeListener": + RegisterFlagChangeListenerParams rfcl = params.registerFlagChangeListener; + listeners.registerFlagChangeListener(rfcl.listenerId, rfcl.callbackUri); + return null; + case "registerFlagValueChangeListener": + RegisterFlagValueChangeListenerParams rfvcl = params.registerFlagValueChangeListener; + listeners.registerFlagValueChangeListener(rfvcl.listenerId, rfvcl.flagKey, rfvcl.context, rfvcl.callbackUri); + return null; + case "unregisterListener": + UnregisterListenerParams ul = params.unregisterListener; + if (!listeners.unregister(ul.listenerId)) { + throw new TestService.BadRequestException("no listener with id " + ul.listenerId); + } + return null; default: throw new TestService.BadRequestException("unknown command: " + params.command); } @@ -372,6 +391,7 @@ private MigrationExecution getExecution(String execution) { } public void close() { + listeners.closeAll(); try { client.close(); } catch (Exception e) { diff --git a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java index 45eeead7..3ed113cf 100644 --- a/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java +++ b/lib/sdk/server/contract-tests/service/src/main/java/sdktest/TestService.java @@ -34,6 +34,8 @@ public class TestService { "event-gzip", "event-sampling", "filtering", + "flag-change-listeners", + "flag-value-change-listeners", "hook-environment-id", "inline-context-all", "migrations",