From 19d9c02d50f2a386e561ae62b6885195655a9233 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Mon, 12 Jan 2026 11:47:17 -0500 Subject: [PATCH 01/48] chore: adds fdv2 payload parsing and protocol handling --- .../sdk/internal/GsonHelpers.java | 6 +- .../internal/fdv2/payloads/DeleteObject.java | 111 ++ .../sdk/internal/fdv2/payloads/Error.java | 91 ++ .../sdk/internal/fdv2/payloads/FDv2Event.java | 252 ++++ .../sdk/internal/fdv2/payloads/Goodbye.java | 68 ++ .../internal/fdv2/payloads/IntentCode.java | 88 ++ .../fdv2/payloads/PayloadTransferred.java | 93 ++ .../sdk/internal/fdv2/payloads/PutObject.java | 134 +++ .../internal/fdv2/payloads/ServerIntent.java | 183 +++ .../internal/fdv2/sources/FDv2ChangeSet.java | 147 +++ .../internal/fdv2/sources/FDv2EventTypes.java | 18 + .../fdv2/sources/FDv2ProtocolHandler.java | 341 ++++++ .../sdk/internal/fdv2/sources/Selector.java | 58 + .../fdv2/payloads/FDv2PayloadsTest.java | 877 ++++++++++++++ .../fdv2/sources/FDv2ProtocolHandlerTest.java | 1066 +++++++++++++++++ 15 files changed, 3532 insertions(+), 1 deletion(-) create mode 100644 lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/DeleteObject.java create mode 100644 lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/Error.java create mode 100644 lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2Event.java create mode 100644 lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/Goodbye.java create mode 100644 lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/IntentCode.java create mode 100644 lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/PayloadTransferred.java create mode 100644 lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/PutObject.java create mode 100644 lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/ServerIntent.java create mode 100644 lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ChangeSet.java create mode 100644 lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2EventTypes.java create mode 100644 lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandler.java create mode 100644 lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/Selector.java create mode 100644 lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2PayloadsTest.java create mode 100644 lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandlerTest.java diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/GsonHelpers.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/GsonHelpers.java index b39928b5..3b4497f7 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/GsonHelpers.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/GsonHelpers.java @@ -1,12 +1,16 @@ package com.launchdarkly.sdk.internal; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.launchdarkly.sdk.internal.fdv2.payloads.IntentCode; /** * General-purpose Gson helpers. */ public abstract class GsonHelpers { - private static final Gson GSON_INSTANCE = new Gson(); + private static final Gson GSON_INSTANCE = new GsonBuilder() + .registerTypeAdapter(IntentCode.class, new IntentCode.IntentCodeTypeAdapter()) + .create(); /** * A singleton instance of Gson with the default configuration. diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/DeleteObject.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/DeleteObject.java new file mode 100644 index 00000000..7959d411 --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/DeleteObject.java @@ -0,0 +1,111 @@ +package com.launchdarkly.sdk.internal.fdv2.payloads; + +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; +import java.util.Objects; + +/** + * Represents the delete-object event, which contains a payload object that should be deleted. + */ +public final class DeleteObject { + private final int version; + private final String kind; + private final String key; + + /** + * Constructs a new DeleteObject. + * + * @param version the minimum payload version this change applies to + * @param kind the kind of object being deleted ("flag" or "segment") + * @param key the identifier of the object being deleted + */ + public DeleteObject(int version, String kind, String key) { + this.version = version; + this.kind = Objects.requireNonNull(kind, "kind"); + this.key = Objects.requireNonNull(key, "key"); + } + + /** + * Returns the minimum payload version this change applies to. + * + * @return the version + */ + public int getVersion() { + return version; + } + + /** + * Returns the kind of the object being deleted ("flag" or "segment"). + * + * @return the kind + */ + public String getKind() { + return kind; + } + + /** + * Returns the identifier of the object being deleted. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Parses a DeleteObject from a JsonReader. + * + * @param reader the JSON reader + * @return the parsed DeleteObject + * @throws SerializationException if the JSON is invalid + */ + public static DeleteObject parse(JsonReader reader) throws SerializationException { + Integer version = null; + String kind = null; + String key = null; + + try { + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw new SerializationException("expected object"); + } + reader.beginObject(); + + while (reader.peek() != JsonToken.END_OBJECT) { + String name = reader.nextName(); + switch (name) { + case "version": + version = reader.nextInt(); + break; + case "kind": + kind = reader.nextString(); + break; + case "key": + key = reader.nextString(); + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + + if (version == null) { + throw new SerializationException("delete object missing required property 'version'"); + } + if (kind == null) { + throw new SerializationException("delete object missing required property 'kind'"); + } + if (key == null) { + throw new SerializationException("delete object missing required property 'key'"); + } + + return new DeleteObject(version, kind, key); + } catch (IOException | RuntimeException e) { + throw new SerializationException(e); + } + } +} + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/Error.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/Error.java new file mode 100644 index 00000000..21639ded --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/Error.java @@ -0,0 +1,91 @@ +package com.launchdarkly.sdk.internal.fdv2.payloads; + +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; +import java.util.Objects; + +/** + * Represents the error event, which indicates an error encountered server-side affecting + * the payload transfer. SDKs must discard partially transferred data. The SDK remains + * connected and expects the server to recover. + */ +public final class Error { + private final String id; + private final String reason; + + /** + * Constructs a new Error. + * + * @param id the unique string identifier of the entity the error relates to + * @param reason human-readable reason the error occurred + */ + public Error(String id, String reason) { + this.id = id; + this.reason = Objects.requireNonNull(reason, "reason"); + } + + /** + * Returns the unique string identifier of the entity the error relates to. + * + * @return the identifier, or null if not present + */ + public String getId() { + return id; + } + + /** + * Returns the human-readable reason the error occurred. + * + * @return the reason + */ + public String getReason() { + return reason; + } + + /** + * Parses an Error from a JsonReader. + * + * @param reader the JSON reader + * @return the parsed Error + * @throws SerializationException if the JSON is invalid + */ + public static Error parse(JsonReader reader) throws SerializationException { + String id = null; + String reason = null; + + try { + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw new SerializationException("expected object"); + } + reader.beginObject(); + + while (reader.peek() != JsonToken.END_OBJECT) { + String name = reader.nextName(); + switch (name) { + case "id": + id = reader.nextString(); + break; + case "reason": + reason = reader.nextString(); + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + + if (reason == null) { + throw new SerializationException("error missing required property 'reason'"); + } + + return new Error(id, reason); + } catch (IOException | RuntimeException e) { + throw new SerializationException(e); + } + } +} + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2Event.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2Event.java new file mode 100644 index 00000000..98fd0711 --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2Event.java @@ -0,0 +1,252 @@ +package com.launchdarkly.sdk.internal.fdv2.payloads; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance; + +/** + * Represents an FDv2 event. This event may be constructed from an SSE event or directly parsed + * from a polling response. + */ +public final class FDv2Event { + private static final String EVENT_SERVER_INTENT = "server-intent"; + private static final String EVENT_PUT_OBJECT = "put-object"; + private static final String EVENT_DELETE_OBJECT = "delete-object"; + private static final String EVENT_PAYLOAD_TRANSFERRED = "payload-transferred"; + private static final String EVENT_ERROR = "error"; + private static final String EVENT_GOODBYE = "goodbye"; + + private final String eventType; + private final JsonElement data; + + /** + * Exception thrown when attempting to deserialize an FDv2Event as the wrong event type. + */ + public static final class FDv2EventTypeMismatchException extends SerializationException { + private static final long serialVersionUID = 1L; + private final String actualEventType; + private final String expectedEventType; + + public FDv2EventTypeMismatchException(String actualEventType, String expectedEventType) { + super(String.format("Cannot deserialize event type '%s' as '%s'.", actualEventType, expectedEventType)); + this.actualEventType = actualEventType; + this.expectedEventType = expectedEventType; + } + + public String getActualEventType() { + return actualEventType; + } + + public String getExpectedEventType() { + return expectedEventType; + } + } + + /** + * Constructs a new FDv2Event. + * + * @param eventType the type of event + * @param data the event data as a raw JSON element + */ + public FDv2Event(String eventType, JsonElement data) { + this.eventType = Objects.requireNonNull(eventType, "eventType"); + this.data = Objects.requireNonNull(data, "data"); + } + + /** + * Returns the event type. + * + * @return the event type + */ + public String getEventType() { + return eventType; + } + + /** + * Returns the event data as a raw JSON element. + * + * @return the event data + */ + public JsonElement getData() { + return data; + } + + /** + * Parses an FDv2Event from a JsonReader. + * + * @param reader the JSON reader + * @return the parsed FDv2Event + * @throws SerializationException if the JSON is invalid + */ + public static FDv2Event parse(JsonReader reader) throws SerializationException { + String eventType = null; + JsonElement data = null; + + try { + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw new SerializationException("expected object"); + } + reader.beginObject(); + + while (reader.peek() != JsonToken.END_OBJECT) { + String name = reader.nextName(); + switch (name) { + case "event": + eventType = reader.nextString(); + break; + case "data": + // Store the raw JSON element for later deserialization based on the event type + data = gsonInstance().fromJson(reader, JsonElement.class); + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + + if (eventType == null) { + throw new SerializationException("event missing required property 'event'"); + } + if (data == null) { + throw new SerializationException("event missing required property 'data'"); + } + + return new FDv2Event(eventType, data); + } catch (IOException | RuntimeException e) { + throw new SerializationException(e); + } + } + + /** + * Deserializes the data element as a ServerIntent. + * + * @return the deserialized ServerIntent + * @throws SerializationException if the event type does not match or the JSON cannot be deserialized + */ + public ServerIntent asServerIntent() throws SerializationException { + return deserializeAs(EVENT_SERVER_INTENT, ServerIntent::parse); + } + + /** + * Deserializes the data element as a PutObject. + */ + public PutObject asPutObject() throws SerializationException { + return deserializeAs(EVENT_PUT_OBJECT, PutObject::parse); + } + + /** + * Deserializes the data element as a DeleteObject. + */ + public DeleteObject asDeleteObject() throws SerializationException { + return deserializeAs(EVENT_DELETE_OBJECT, DeleteObject::parse); + } + + /** + * Deserializes the data element as a PayloadTransferred. + */ + public PayloadTransferred asPayloadTransferred() throws SerializationException { + return deserializeAs(EVENT_PAYLOAD_TRANSFERRED, PayloadTransferred::parse); + } + + /** + * Deserializes the data element as an Error. + */ + public Error asError() throws SerializationException { + return deserializeAs(EVENT_ERROR, Error::parse); + } + + /** + * Deserializes the data element as a Goodbye. + */ + public Goodbye asGoodbye() throws SerializationException { + return deserializeAs(EVENT_GOODBYE, Goodbye::parse); + } + + /** + * Deserializes an FDv2 polling response containing an "events" array. + * + * @param jsonString JSON string with an "events" array + * @return the list of deserialized events + * @throws SerializationException if the JSON is malformed or an event cannot be deserialized + */ + public static List parseEventsArray(String jsonString) throws SerializationException { + JsonObject root; + try { + root = gsonInstance().fromJson(jsonString, JsonObject.class); + } catch (RuntimeException e) { + throw new SerializationException(e); + } + + if (root == null || !root.has("events")) { + throw new SerializationException("FDv2 polling response missing 'events' property"); + } + + JsonElement eventsElement = root.get("events"); + if (!eventsElement.isJsonArray()) { + throw new SerializationException("FDv2 polling response 'events' is not an array"); + } + + JsonArray eventsArray = eventsElement.getAsJsonArray(); + List events = new ArrayList<>(eventsArray.size()); + int index = 0; + for (JsonElement eventElement : eventsArray) { + if (eventElement == null || eventElement.isJsonNull()) { + throw new SerializationException("FDv2 polling response contains null event at index " + index); + } + events.add(parseEventElement(eventElement, index)); + index++; + } + return events; + } + + private static FDv2Event parseEventElement(JsonElement element, int index) throws SerializationException { + if (!element.isJsonObject()) { + throw new SerializationException("FDv2 polling response event at index " + index + " is not an object"); + } + + JsonObject obj = element.getAsJsonObject(); + JsonElement eventTypeElement = obj.get("event"); + JsonElement dataElement = obj.get("data"); + + if (eventTypeElement == null || eventTypeElement.isJsonNull()) { + throw new SerializationException("event at index " + index + " missing required property 'event'"); + } + if (dataElement == null || dataElement.isJsonNull()) { + throw new SerializationException("event at index " + index + " missing required property 'data'"); + } + + return new FDv2Event(eventTypeElement.getAsString(), dataElement); + } + + private T deserializeAs(String expectedEventType, Parser parser) throws SerializationException { + if (!expectedEventType.equals(eventType)) { + throw new FDv2EventTypeMismatchException(eventType, expectedEventType); + } + + try { + JsonReader reader = new JsonReader(new StringReader(data.toString())); + return parser.parse(reader); + } catch (SerializationException e) { + throw e; + } catch (Exception e) { + throw new SerializationException(e); + } + } + + private interface Parser { + T parse(JsonReader reader) throws Exception; + } +} + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/Goodbye.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/Goodbye.java new file mode 100644 index 00000000..01c5272c --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/Goodbye.java @@ -0,0 +1,68 @@ +package com.launchdarkly.sdk.internal.fdv2.payloads; + +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; + +/** + * Represents the goodbye event, which indicates that the server is about to disconnect. + */ +public final class Goodbye { + private final String reason; + + /** + * Constructs a new Goodbye. + * + * @param reason reason for the disconnection + */ + public Goodbye(String reason) { + this.reason = reason; + } + + /** + * Returns the reason for the disconnection. + * + * @return the reason + */ + public String getReason() { + return reason; + } + + /** + * Parses a Goodbye from a JsonReader. + * + * @param reader the JSON reader + * @return the parsed Goodbye + * @throws SerializationException if the JSON is invalid + */ + public static Goodbye parse(JsonReader reader) throws SerializationException { + String reason = null; + + try { + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw new SerializationException("expected object"); + } + reader.beginObject(); + + while (reader.peek() != JsonToken.END_OBJECT) { + String name = reader.nextName(); + switch (name) { + case "reason": + reason = reader.nextString(); + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + + return new Goodbye(reason); + } catch (IOException | RuntimeException e) { + throw new SerializationException(e); + } + } +} + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/IntentCode.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/IntentCode.java new file mode 100644 index 00000000..21e252d1 --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/IntentCode.java @@ -0,0 +1,88 @@ +package com.launchdarkly.sdk.internal.fdv2.payloads; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; + +/** + * Represents the intent code indicating how the server intends to transfer data. + */ +public enum IntentCode { + NONE("none"), + TRANSFER_FULL("xfer-full"), + TRANSFER_CHANGES("xfer-changes"); + + private final String stringValue; + + IntentCode(String stringValue) { + this.stringValue = stringValue; + } + + /** + * Returns the string representation of the intent code. + * + * @return the string value + */ + public String getStringValue() { + return stringValue; + } + + /** + * Parses a string into an IntentCode. + * + * @param value the string value + * @return the parsed IntentCode + * @throws SerializationException if the value is unknown or null + */ + public static IntentCode parse(String value) throws SerializationException { + if (value == null) { + throw new SerializationException("intentCode missing required value"); + } + + switch (value) { + case "none": + return NONE; + case "xfer-full": + return TRANSFER_FULL; + case "xfer-changes": + return TRANSFER_CHANGES; + default: + throw new SerializationException("unknown intent code: " + value); + } + } + + @Override + public String toString() { + return stringValue; + } + + /** + * Gson TypeAdapter for serializing and deserializing IntentCode. + * Serializes using the string value (e.g., "xfer-full") rather than the enum name. + */ + public static final class IntentCodeTypeAdapter extends TypeAdapter { + @Override + public void write(JsonWriter out, IntentCode value) throws IOException { + if (value == null) { + out.nullValue(); + } else { + out.value(value.getStringValue()); + } + } + + @Override + public IntentCode read(JsonReader in) throws IOException { + String value = in.nextString(); + try { + return IntentCode.parse(value); + } catch (SerializationException e) { + throw new IOException(e); + } + } + } +} + + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/PayloadTransferred.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/PayloadTransferred.java new file mode 100644 index 00000000..916b80ad --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/PayloadTransferred.java @@ -0,0 +1,93 @@ +package com.launchdarkly.sdk.internal.fdv2.payloads; + +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; +import java.util.Objects; + +/** + * Represents the payload-transferred event, which is sent after all messages for a payload update + * have been transmitted. + */ +public final class PayloadTransferred { + private final String state; + private final int version; + + /** + * Constructs a new PayloadTransferred. + * + * @param state the unique string representing the payload state + * @param version the version of the payload that was transferred to the client + */ + public PayloadTransferred(String state, int version) { + this.state = Objects.requireNonNull(state, "state"); + this.version = version; + } + + /** + * Returns the unique string representing the payload state. + * + * @return the state + */ + public String getState() { + return state; + } + + /** + * Returns the version of the payload that was transferred. + * + * @return the version + */ + public int getVersion() { + return version; + } + + /** + * Parses a PayloadTransferred from a JsonReader. + * + * @param reader the JSON reader + * @return the parsed PayloadTransferred + * @throws SerializationException if the JSON is invalid + */ + public static PayloadTransferred parse(JsonReader reader) throws SerializationException { + String state = null; + Integer version = null; + + try { + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw new SerializationException("expected object"); + } + reader.beginObject(); + + while (reader.peek() != JsonToken.END_OBJECT) { + String name = reader.nextName(); + switch (name) { + case "state": + state = reader.nextString(); + break; + case "version": + version = reader.nextInt(); + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + + if (state == null) { + throw new SerializationException("payload-transferred missing required property 'state'"); + } + if (version == null) { + throw new SerializationException("payload-transferred missing required property 'version'"); + } + + return new PayloadTransferred(state, version); + } catch (IOException | RuntimeException e) { + throw new SerializationException(e); + } + } +} + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/PutObject.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/PutObject.java new file mode 100644 index 00000000..80cd3abb --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/PutObject.java @@ -0,0 +1,134 @@ +package com.launchdarkly.sdk.internal.fdv2.payloads; + +import com.google.gson.JsonElement; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; +import java.util.Objects; + +import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance; + +/** + * Represents the put-object event, which contains a payload object that should be accepted with + * upsert semantics. The object can be either a flag or a segment. + */ +public final class PutObject { + private final int version; + private final String kind; + private final String key; + private final JsonElement object; + + /** + * Constructs a new PutObject. + * + * @param version the minimum payload version this change applies to + * @param kind the kind of object being PUT ("flag" or "segment") + * @param key the identifier of the object + * @param object the raw JSON object being PUT + */ + public PutObject(int version, String kind, String key, JsonElement object) { + this.version = version; + this.kind = Objects.requireNonNull(kind, "kind"); + this.key = Objects.requireNonNull(key, "key"); + this.object = Objects.requireNonNull(object, "object"); + } + + /** + * Returns the minimum payload version this change applies to. + * + * @return the version + */ + public int getVersion() { + return version; + } + + /** + * Returns the kind of the object being PUT ("flag" or "segment"). + * + * @return the kind + */ + public String getKind() { + return kind; + } + + /** + * Returns the identifier of the object. + * + * @return the key + */ + public String getKey() { + return key; + } + + /** + * Returns the raw JSON object being PUT. + * + * @return the object + */ + public JsonElement getObject() { + return object; + } + + /** + * Parses a PutObject from a JsonReader. + * + * @param reader the JSON reader + * @return the parsed PutObject + * @throws SerializationException if the JSON is invalid + */ + public static PutObject parse(JsonReader reader) throws SerializationException { + Integer version = null; + String kind = null; + String key = null; + JsonElement object = null; + + try { + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw new SerializationException("expected object"); + } + reader.beginObject(); + + while (reader.peek() != JsonToken.END_OBJECT) { + String name = reader.nextName(); + switch (name) { + case "version": + version = reader.nextInt(); + break; + case "kind": + kind = reader.nextString(); + break; + case "key": + key = reader.nextString(); + break; + case "object": + object = gsonInstance().fromJson(reader, JsonElement.class); + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + + if (version == null) { + throw new SerializationException("put object missing required property 'version'"); + } + if (kind == null) { + throw new SerializationException("put object missing required property 'kind'"); + } + if (key == null) { + throw new SerializationException("put object missing required property 'key'"); + } + if (object == null) { + throw new SerializationException("put object missing required property 'object'"); + } + + return new PutObject(version, kind, key, object); + } catch (IOException | RuntimeException e) { + throw new SerializationException(e); + } + } +} + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/ServerIntent.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/ServerIntent.java new file mode 100644 index 00000000..897a7fc4 --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/ServerIntent.java @@ -0,0 +1,183 @@ +package com.launchdarkly.sdk.internal.fdv2.payloads; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.launchdarkly.sdk.json.SerializationException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance; + +/** + * Represents the server-intent event, which is the first message sent by flag delivery upon + * connecting to FDv2. Contains information about how flag delivery intends to handle payloads. + */ +public final class ServerIntent { + private final List payloads; + + /** + * Constructs a new ServerIntent. + * + * @param payloads the payloads the server will be transferring data for + */ + public ServerIntent(List payloads) { + this.payloads = Collections.unmodifiableList(new ArrayList<>(Objects.requireNonNull(payloads, "payloads"))); + } + + /** + * Returns the list of payloads the server will be transferring data for. + * + * @return the payloads + */ + public List getPayloads() { + return payloads; + } + + /** + * Parses a ServerIntent from a JsonReader. + * + * @param reader the JSON reader + * @return the parsed ServerIntent + * @throws SerializationException if the JSON is invalid + */ + public static ServerIntent parse(JsonReader reader) throws SerializationException { + List payloads = null; + + try { + if (reader.peek() != JsonToken.BEGIN_OBJECT) { + throw new SerializationException("expected object"); + } + reader.beginObject(); + + while (reader.peek() != JsonToken.END_OBJECT) { + String name = reader.nextName(); + switch (name) { + case "payloads": + JsonArray payloadArray = gsonInstance().fromJson(reader, JsonArray.class); + payloads = new ArrayList<>(payloadArray.size()); + int index = 0; + for (JsonElement payloadElement : payloadArray) { + if (payloadElement == null || payloadElement.isJsonNull()) { + throw new SerializationException("server-intent contains null payload at index " + index); + } + payloads.add(ServerIntentPayload.parse(payloadElement)); + index++; + } + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + + if (payloads == null) { + throw new SerializationException("server-intent missing required property 'payloads'"); + } + + return new ServerIntent(payloads); + } catch (IOException | RuntimeException e) { + throw new SerializationException(e); + } + } + + /** + * Description of server intent to transfer a specific payload. + */ + public static final class ServerIntentPayload { + private final String id; + private final int target; + private final IntentCode intentCode; + private final String reason; + + /** + * Constructs a new ServerIntentPayload. + * + * @param id the unique string identifier + * @param target the target version for the payload + * @param intentCode how the server intends to operate with respect to sending payload data + * @param reason reason the server is operating with the provided code + */ + public ServerIntentPayload(String id, int target, IntentCode intentCode, String reason) { + this.id = Objects.requireNonNull(id, "id"); + this.target = target; + this.intentCode = Objects.requireNonNull(intentCode, "intentCode"); + this.reason = Objects.requireNonNull(reason, "reason"); + } + + public String getId() { + return id; + } + + public int getTarget() { + return target; + } + + public IntentCode getIntentCode() { + return intentCode; + } + + public String getReason() { + return reason; + } + + static ServerIntentPayload parse(JsonElement element) throws SerializationException { + String id = null; + Integer target = null; + IntentCode intentCode = null; + String reason = null; + + if (!element.isJsonObject()) { + throw new SerializationException("expected payload object"); + } + + for (Map.Entry entry : element.getAsJsonObject().entrySet()) { + String name = entry.getKey(); + JsonElement value = entry.getValue(); + switch (name) { + case "id": + id = value.isJsonNull() ? null : value.getAsString(); + break; + case "target": + if (!value.isJsonNull()) { + target = value.getAsInt(); + } + break; + case "intentCode": + if (!value.isJsonNull()) { + intentCode = IntentCode.parse(value.getAsString()); + } + break; + case "reason": + reason = value.isJsonNull() ? null : value.getAsString(); + break; + default: + break; + } + } + + if (id == null) { + throw new SerializationException("server-intent payload missing required property 'id'"); + } + if (target == null) { + throw new SerializationException("server-intent payload missing required property 'target'"); + } + if (intentCode == null) { + throw new SerializationException("server-intent payload missing required property 'intentCode'"); + } + if (reason == null) { + throw new SerializationException("server-intent payload missing required property 'reason'"); + } + + return new ServerIntentPayload(id, target, intentCode, reason); + } + } +} + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ChangeSet.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ChangeSet.java new file mode 100644 index 00000000..15fafdbf --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ChangeSet.java @@ -0,0 +1,147 @@ +package com.launchdarkly.sdk.internal.fdv2.sources; + +import com.google.gson.JsonElement; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Change tracking structures for FDv2. + */ +public final class FDv2ChangeSet { + /** + * Represents the type of change operation. + */ + public enum FDv2ChangeType { + /** + * Indicates an upsert operation (insert or update). + */ + PUT, + + /** + * Indicates a delete operation. + */ + DELETE + } + + /** + * Represents the type of changeset. + */ + public enum FDv2ChangeSetType { + /** + * Changeset represents a full payload to use as a basis. + */ + FULL, + + /** + * Changeset represents a partial payload to be applied to a basis. + */ + PARTIAL, + + /** + * A changeset which indicates that no changes should be made. + */ + NONE + } + + /** + * Represents a single change to a data object. + */ + public static final class FDv2Change { + private final FDv2ChangeType type; + private final String kind; + private final String key; + private final int version; + private final JsonElement object; + + /** + * Constructs a new Change. + * + * @param type the type of change operation + * @param kind the kind of object being changed + * @param key the key identifying the object + * @param version the version of the change + * @param object the raw JSON representing the object data (required for put operations) + */ + public FDv2Change(FDv2ChangeType type, String kind, String key, int version, JsonElement object) { + this.type = Objects.requireNonNull(type, "type"); + this.kind = Objects.requireNonNull(kind, "kind"); + this.key = Objects.requireNonNull(key, "key"); + this.version = version; + this.object = object; + } + + public FDv2ChangeType getType() { + return type; + } + + public String getKind() { + return kind; + } + + public String getKey() { + return key; + } + + public int getVersion() { + return version; + } + + /** + * The raw JSON string representing the object data (only present for Put operations). + */ + public JsonElement getObject() { + return object; + } + } + + private final FDv2ChangeSetType type; + private final List changes; + private final Selector selector; + + /** + * Constructs a new ChangeSet. + * + * @param type the type of the changeset + * @param changes the list of changes (required) + * @param selector the selector for this changeset + */ + public FDv2ChangeSet(FDv2ChangeSetType type, List changes, Selector selector) { + this.type = Objects.requireNonNull(type, "type"); + this.changes = Collections.unmodifiableList(Objects.requireNonNull(changes, "changes")); + this.selector = selector; + } + + /** + * The intent code indicating how the server intends to transfer data. + */ + public FDv2ChangeSetType getType() { + return type; + } + + /** + * The list of changes in this changeset. May be empty if there are no changes. + */ + public List getChanges() { + return changes; + } + + /** + * The selector (version identifier) for this changeset. + */ + public Selector getSelector() { + return selector; + } + + /** + * An empty changeset that indicates no changes are required. + */ + public static final FDv2ChangeSet NONE = new FDv2ChangeSet( + FDv2ChangeSetType.NONE, + Collections.emptyList(), + Selector.EMPTY + ); +} + + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2EventTypes.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2EventTypes.java new file mode 100644 index 00000000..b89aed2c --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2EventTypes.java @@ -0,0 +1,18 @@ +package com.launchdarkly.sdk.internal.fdv2.sources; + +/** + * Types of events that FDv2 can receive. + */ +public final class FDv2EventTypes { + private FDv2EventTypes() {} + + public static final String SERVER_INTENT = "server-intent"; + public static final String PUT_OBJECT = "put-object"; + public static final String DELETE_OBJECT = "delete-object"; + public static final String ERROR = "error"; + public static final String GOODBYE = "goodbye"; + public static final String HEARTBEAT = "heartbeat"; + public static final String PAYLOAD_TRANSFERRED = "payload-transferred"; +} + + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandler.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandler.java new file mode 100644 index 00000000..c7ee8c74 --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandler.java @@ -0,0 +1,341 @@ +package com.launchdarkly.sdk.internal.fdv2.sources; + +import com.launchdarkly.sdk.internal.fdv2.payloads.DeleteObject; +import com.launchdarkly.sdk.internal.fdv2.payloads.Error; +import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event; +import com.launchdarkly.sdk.internal.fdv2.payloads.Goodbye; +import com.launchdarkly.sdk.internal.fdv2.payloads.PayloadTransferred; +import com.launchdarkly.sdk.internal.fdv2.payloads.PutObject; +import com.launchdarkly.sdk.internal.fdv2.payloads.ServerIntent; +import com.launchdarkly.sdk.json.SerializationException; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Implements the FDv2 protocol state machine for handling payload communication events. + * See: FDV2PL-payload-communication specification. + */ +public final class FDv2ProtocolHandler { + /** + * State of the protocol handler. + */ + private enum FDv2ProtocolState { + /** + * No server intent has been expressed. + */ + INACTIVE, + /** + * Currently receiving incremental changes. + */ + CHANGES, + /** + * Currently receiving a full transfer. + */ + FULL + } + + /** + * Actions emitted by the protocol handler. + */ + public enum FDv2ProtocolActionType { + /** + * Indicates that a changeset should be emitted. + */ + CHANGESET, + /** + * Indicates that an error has been encountered and should be logged. + */ + ERROR, + /** + * Indicates that the server intends to disconnect and the SDK should log the reason. + */ + GOODBYE, + /** + * Indicates that no special action should be taken. + */ + NONE, + /** + * Indicates an internal error that should be logged. + */ + INTERNAL_ERROR + } + + /** + * Error categories produced by the protocol handler. + */ + public enum FDv2ProtocolErrorType { + /** + * Received a protocol event which is not recognized. + */ + UNKNOWN_EVENT, + /** + * Server intent was received without any payloads. + */ + MISSING_PAYLOAD, + /** + * The JSON couldn't be parsed or didn't conform to the schema. + */ + JSON_ERROR, + /** + * Represents an implementation defect. + */ + IMPLEMENTATION_ERROR, + /** + * Represents a violation of the protocol flow. + */ + PROTOCOL_ERROR + } + + public interface IFDv2ProtocolAction { + FDv2ProtocolActionType getAction(); + } + + public static final class FDv2ActionChangeset implements IFDv2ProtocolAction { + private final FDv2ChangeSet changeset; + + public FDv2ActionChangeset(FDv2ChangeSet changeset) { + this.changeset = Objects.requireNonNull(changeset, "changeset"); + } + + @Override + public FDv2ProtocolActionType getAction() { + return FDv2ProtocolActionType.CHANGESET; + } + + public FDv2ChangeSet getChangeset() { + return changeset; + } + } + + public static final class FDv2ActionError implements IFDv2ProtocolAction { + private final String id; + private final String reason; + + public FDv2ActionError(String id, String reason) { + this.id = id; + this.reason = reason; + } + + @Override + public FDv2ProtocolActionType getAction() { + return FDv2ProtocolActionType.ERROR; + } + + public String getId() { + return id; + } + + public String getReason() { + return reason; + } + } + + public static final class FDv2ActionGoodbye implements IFDv2ProtocolAction { + private final String reason; + + public FDv2ActionGoodbye(String reason) { + this.reason = reason; + } + + @Override + public FDv2ProtocolActionType getAction() { + return FDv2ProtocolActionType.GOODBYE; + } + + public String getReason() { + return reason; + } + } + + public static final class FDv2ActionInternalError implements IFDv2ProtocolAction { + private final String message; + private final FDv2ProtocolErrorType errorType; + + public FDv2ActionInternalError(String message, FDv2ProtocolErrorType errorType) { + this.message = message; + this.errorType = errorType; + } + + @Override + public FDv2ProtocolActionType getAction() { + return FDv2ProtocolActionType.INTERNAL_ERROR; + } + + public String getMessage() { + return message; + } + + public FDv2ProtocolErrorType getErrorType() { + return errorType; + } + } + + public static final class FDv2ActionNone implements IFDv2ProtocolAction { + private static final FDv2ActionNone INSTANCE = new FDv2ActionNone(); + + private FDv2ActionNone() {} + + public static FDv2ActionNone getInstance() { + return INSTANCE; + } + + @Override + public FDv2ProtocolActionType getAction() { + return FDv2ProtocolActionType.NONE; + } + } + + private final List changes = new ArrayList<>(); + private FDv2ProtocolState state = FDv2ProtocolState.INACTIVE; + + private IFDv2ProtocolAction serverIntent(ServerIntent intent) { + List payloads = intent.getPayloads(); + ServerIntent.ServerIntentPayload payload = (payloads == null || payloads.isEmpty()) + ? null : payloads.get(0); + if (payload == null) { + return new FDv2ActionInternalError("No payload present in server-intent", + FDv2ProtocolErrorType.MISSING_PAYLOAD); + } + + switch (payload.getIntentCode()) { + case NONE: + state = FDv2ProtocolState.CHANGES; + changes.clear(); + return new FDv2ActionChangeset(FDv2ChangeSet.NONE); + case TRANSFER_FULL: + state = FDv2ProtocolState.FULL; + break; + case TRANSFER_CHANGES: + state = FDv2ProtocolState.CHANGES; + break; + default: + return new FDv2ActionInternalError("Unhandled event code: " + payload.getIntentCode(), + FDv2ProtocolErrorType.IMPLEMENTATION_ERROR); + } + + changes.clear(); + return FDv2ActionNone.getInstance(); + } + + private void putObject(PutObject put) { + changes.add(new FDv2ChangeSet.FDv2Change( + FDv2ChangeSet.FDv2ChangeType.PUT, put.getKind(), put.getKey(), put.getVersion(), put.getObject())); + } + + private void deleteObject(DeleteObject delete) { + changes.add(new FDv2ChangeSet.FDv2Change( + FDv2ChangeSet.FDv2ChangeType.DELETE, delete.getKind(), delete.getKey(), delete.getVersion(), null)); + } + + private IFDv2ProtocolAction payloadTransferred(PayloadTransferred payload) { + FDv2ChangeSet.FDv2ChangeSetType changeSetType; + switch (state) { + case INACTIVE: + return new FDv2ActionInternalError( + "A payload transferred has been received without an intent having been established.", + FDv2ProtocolErrorType.PROTOCOL_ERROR); + case CHANGES: + changeSetType = FDv2ChangeSet.FDv2ChangeSetType.PARTIAL; + break; + case FULL: + changeSetType = FDv2ChangeSet.FDv2ChangeSetType.FULL; + break; + default: + return new FDv2ActionInternalError("Unhandled protocol state: " + state, + FDv2ProtocolErrorType.IMPLEMENTATION_ERROR); + } + + FDv2ChangeSet changeset = new FDv2ChangeSet( + changeSetType, + new ArrayList<>(changes), + Selector.make(payload.getVersion(), payload.getState())); + state = FDv2ProtocolState.CHANGES; + changes.clear(); + return new FDv2ActionChangeset(changeset); + } + + private IFDv2ProtocolAction error(Error error) { + changes.clear(); + return new FDv2ActionError(error.getId(), error.getReason()); + } + + private IFDv2ProtocolAction goodbye(Goodbye intent) { + return new FDv2ActionGoodbye(intent.getReason()); + } + + /** + * Process an FDv2 event and update the protocol state accordingly. + * + * @param evt the event to process + * @return an action indicating what the caller should do in response to this event + */ + public IFDv2ProtocolAction handleEvent(FDv2Event evt) { + try { + switch (evt.getEventType()) { + case FDv2EventTypes.SERVER_INTENT: + return serverIntent(evt.asServerIntent()); + case FDv2EventTypes.DELETE_OBJECT: + deleteObject(evt.asDeleteObject()); + break; + case FDv2EventTypes.PUT_OBJECT: + putObject(evt.asPutObject()); + break; + case FDv2EventTypes.ERROR: + return error(evt.asError()); + case FDv2EventTypes.GOODBYE: + return goodbye(evt.asGoodbye()); + case FDv2EventTypes.PAYLOAD_TRANSFERRED: + return payloadTransferred(evt.asPayloadTransferred()); + case FDv2EventTypes.HEARTBEAT: + break; + default: + return new FDv2ActionInternalError( + "Received an unknown event of type " + evt.getEventType(), + FDv2ProtocolErrorType.UNKNOWN_EVENT); + } + + return FDv2ActionNone.getInstance(); + } catch (FDv2Event.FDv2EventTypeMismatchException ex) { + return new FDv2ActionInternalError( + "Event type mismatch: " + ex.getMessage(), + FDv2ProtocolErrorType.IMPLEMENTATION_ERROR); + } catch (SerializationException ex) { + return new FDv2ActionInternalError( + "Failed to deserialize " + evt.getEventType() + " event: " + ex.getMessage(), + FDv2ProtocolErrorType.JSON_ERROR); + } + } + + /** + * Get a list of event types which are handled by the protocol handler. + */ + public static List getHandledEventTypes() { + return HANDLED_EVENT_TYPES; + } + + private static final List HANDLED_EVENT_TYPES; + static { + List types = new ArrayList<>(); + types.add(FDv2EventTypes.SERVER_INTENT); + types.add(FDv2EventTypes.DELETE_OBJECT); + types.add(FDv2EventTypes.PUT_OBJECT); + types.add(FDv2EventTypes.ERROR); + types.add(FDv2EventTypes.GOODBYE); + types.add(FDv2EventTypes.PAYLOAD_TRANSFERRED); + types.add(FDv2EventTypes.HEARTBEAT); + HANDLED_EVENT_TYPES = Collections.unmodifiableList(types); + } + + /** + * Reset the protocol handler. This should be done whenever a connection to the source of data is reset. + */ + public void reset() { + changes.clear(); + state = FDv2ProtocolState.INACTIVE; + } +} + + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/Selector.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/Selector.java new file mode 100644 index 00000000..79f83a34 --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/Selector.java @@ -0,0 +1,58 @@ +package com.launchdarkly.sdk.internal.fdv2.sources; + +/** + * A selector can either be empty or it can contain state and a version. + */ +public final class Selector { + private final boolean isEmpty; + private final int version; + private final String state; + + private Selector(int version, String state, boolean isEmpty) { + this.version = version; + this.state = state; + this.isEmpty = isEmpty; + } + + /** + * If true, then this selector is empty. An empty selector cannot be used as a basis for a data source. + * + * @return whether the selector is empty + */ + public boolean isEmpty() { + return isEmpty; + } + + /** + * The version of the data associated with this selector. + * + * @return the version + */ + public int getVersion() { + return version; + } + + /** + * The state associated with the payload. + * + * @return the state identifier, or null if empty + */ + public String getState() { + return state; + } + + static Selector empty() { + return new Selector(0, null, true); + } + + static Selector make(int version, String state) { + return new Selector(version, state, false); + } + + /** + * An empty selector instance. + */ + public static final Selector EMPTY = empty(); +} + + diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2PayloadsTest.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2PayloadsTest.java new file mode 100644 index 00000000..db6f7851 --- /dev/null +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2PayloadsTest.java @@ -0,0 +1,877 @@ +package com.launchdarkly.sdk.internal.fdv2; + +import com.google.gson.JsonElement; +import com.google.gson.stream.JsonReader; +import com.launchdarkly.sdk.internal.BaseInternalTest; +import com.launchdarkly.sdk.internal.fdv2.payloads.DeleteObject; +import com.launchdarkly.sdk.internal.fdv2.payloads.Error; +import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event; +import com.launchdarkly.sdk.internal.fdv2.payloads.Goodbye; +import com.launchdarkly.sdk.internal.fdv2.payloads.IntentCode; +import com.launchdarkly.sdk.internal.fdv2.payloads.PayloadTransferred; +import com.launchdarkly.sdk.internal.fdv2.payloads.PutObject; +import com.launchdarkly.sdk.internal.fdv2.payloads.ServerIntent; +import com.launchdarkly.sdk.json.SerializationException; + +import org.junit.Test; + +import java.io.StringReader; +import java.util.List; + +import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +@SuppressWarnings("javadoc") +public class FDv2PayloadsTest extends BaseInternalTest { + + @Test + public void serverIntent_CanDeserializeAndReserialize() throws Exception { + String json = "{\n" + + " \"payloads\": [\n" + + " {\n" + + " \"id\": \"payload-123\",\n" + + " \"target\": 42,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }\n" + + " ]\n" + + "}"; + + ServerIntent serverIntent = ServerIntent.parse(new JsonReader(new StringReader(json))); + + assertNotNull(serverIntent); + assertEquals(1, serverIntent.getPayloads().size()); + assertEquals("payload-123", serverIntent.getPayloads().get(0).getId()); + assertEquals(42, serverIntent.getPayloads().get(0).getTarget()); + assertEquals(IntentCode.TRANSFER_FULL, serverIntent.getPayloads().get(0).getIntentCode()); + assertEquals("payload-missing", serverIntent.getPayloads().get(0).getReason()); + + // Reserialize and verify + String reserialized = gsonInstance().toJson(serverIntent); + ServerIntent deserialized2 = ServerIntent.parse(new JsonReader(new StringReader(reserialized))); + assertEquals("payload-123", deserialized2.getPayloads().get(0).getId()); + assertEquals(42, deserialized2.getPayloads().get(0).getTarget()); + assertEquals(IntentCode.TRANSFER_FULL, deserialized2.getPayloads().get(0).getIntentCode()); + assertEquals("payload-missing", deserialized2.getPayloads().get(0).getReason()); + } + + @Test + public void serverIntent_CanDeserializeMultiplePayloads() throws Exception { + String json = "{\n" + + " \"payloads\": [\n" + + " {\n" + + " \"id\": \"payload-1\",\n" + + " \"target\": 10,\n" + + " \"intentCode\": \"xfer-changes\",\n" + + " \"reason\": \"stale\"\n" + + " },\n" + + " {\n" + + " \"id\": \"payload-2\",\n" + + " \"target\": 20,\n" + + " \"intentCode\": \"none\",\n" + + " \"reason\": \"up-to-date\"\n" + + " }\n" + + " ]\n" + + "}"; + + ServerIntent serverIntent = ServerIntent.parse(new JsonReader(new StringReader(json))); + + assertNotNull(serverIntent); + assertEquals(2, serverIntent.getPayloads().size()); + assertEquals("payload-1", serverIntent.getPayloads().get(0).getId()); + assertEquals(IntentCode.TRANSFER_CHANGES, serverIntent.getPayloads().get(0).getIntentCode()); + assertEquals("payload-2", serverIntent.getPayloads().get(1).getId()); + assertEquals(IntentCode.NONE, serverIntent.getPayloads().get(1).getIntentCode()); + } + + @Test + public void putObject_CanDeserializeWithFlag() throws Exception { + String json = "{\n" + + " \"version\": 10,\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"test-flag\",\n" + + " \"object\": {\n" + + " \"key\": \"test-flag\",\n" + + " \"version\": 5,\n" + + " \"on\": true,\n" + + " \"fallthrough\": { \"variation\": 0 },\n" + + " \"offVariation\": 1,\n" + + " \"variations\": [true, false],\n" + + " \"salt\": \"abc123\",\n" + + " \"trackEvents\": false,\n" + + " \"trackEventsFallthrough\": false,\n" + + " \"debugEventsUntilDate\": null,\n" + + " \"clientSide\": true,\n" + + " \"deleted\": false\n" + + " }\n" + + "}"; + + PutObject putObject = PutObject.parse(new JsonReader(new StringReader(json))); + + assertNotNull(putObject); + assertEquals(10, putObject.getVersion()); + assertEquals("flag", putObject.getKind()); + assertEquals("test-flag", putObject.getKey()); + + // Verify the object JsonElement contains the expected flag data + JsonElement objectElement = putObject.getObject(); + assertNotNull(objectElement); + assertTrue(objectElement.isJsonObject()); + assertEquals("test-flag", objectElement.getAsJsonObject().get("key").getAsString()); + assertEquals(5, objectElement.getAsJsonObject().get("version").getAsInt()); + assertTrue(objectElement.getAsJsonObject().get("on").getAsBoolean()); + assertEquals("abc123", objectElement.getAsJsonObject().get("salt").getAsString()); + } + + @Test + public void putObject_CanReserializeWithFlag() throws Exception { + // Create a flag JSON + String flagJson = "{\n" + + " \"key\": \"my-flag\",\n" + + " \"version\": 3,\n" + + " \"on\": true,\n" + + " \"fallthrough\": { \"variation\": 0 },\n" + + " \"offVariation\": 1,\n" + + " \"variations\": [true, false],\n" + + " \"salt\": \"salt123\",\n" + + " \"clientSide\": true,\n" + + " \"deleted\": false\n" + + "}"; + + JsonElement flagElement = gsonInstance().fromJson(flagJson, JsonElement.class); + PutObject putObject = new PutObject(15, "flag", "my-flag", flagElement); + + String serialized = gsonInstance().toJson(putObject); + PutObject deserialized = PutObject.parse(new JsonReader(new StringReader(serialized))); + + assertEquals(15, deserialized.getVersion()); + assertEquals("flag", deserialized.getKind()); + assertEquals("my-flag", deserialized.getKey()); + + JsonElement deserializedFlagElement = deserialized.getObject(); + assertEquals("my-flag", deserializedFlagElement.getAsJsonObject().get("key").getAsString()); + assertEquals(3, deserializedFlagElement.getAsJsonObject().get("version").getAsInt()); + assertTrue(deserializedFlagElement.getAsJsonObject().get("on").getAsBoolean()); + assertEquals("salt123", deserializedFlagElement.getAsJsonObject().get("salt").getAsString()); + assertTrue(deserializedFlagElement.getAsJsonObject().get("clientSide").getAsBoolean()); + assertEquals(0, deserializedFlagElement.getAsJsonObject().get("fallthrough") + .getAsJsonObject().get("variation").getAsInt()); + assertEquals(1, deserializedFlagElement.getAsJsonObject().get("offVariation").getAsInt()); + assertEquals(2, deserializedFlagElement.getAsJsonObject().get("variations").getAsJsonArray().size()); + assertTrue(deserializedFlagElement.getAsJsonObject().get("variations").getAsJsonArray().get(0).getAsBoolean()); + } + + @Test + public void putObject_CanDeserializeWithSegment() throws Exception { + String json = "{\n" + + " \"version\": 20,\n" + + " \"kind\": \"segment\",\n" + + " \"key\": \"test-segment\",\n" + + " \"object\": {\n" + + " \"key\": \"test-segment\",\n" + + " \"version\": 7,\n" + + " \"included\": [\"user1\", \"user2\"],\n" + + " \"salt\": \"seg-salt\",\n" + + " \"deleted\": false\n" + + " }\n" + + "}"; + + PutObject putObject = PutObject.parse(new JsonReader(new StringReader(json))); + + assertNotNull(putObject); + assertEquals(20, putObject.getVersion()); + assertEquals("segment", putObject.getKind()); + assertEquals("test-segment", putObject.getKey()); + + // Verify the object JsonElement contains the expected segment data + JsonElement objectElement = putObject.getObject(); + assertNotNull(objectElement); + assertTrue(objectElement.isJsonObject()); + assertEquals("test-segment", objectElement.getAsJsonObject().get("key").getAsString()); + assertEquals(7, objectElement.getAsJsonObject().get("version").getAsInt()); + assertEquals(2, objectElement.getAsJsonObject().get("included").getAsJsonArray().size()); + assertTrue(objectElement.getAsJsonObject().get("included").getAsJsonArray().toString().contains("user1")); + assertTrue(objectElement.getAsJsonObject().get("included").getAsJsonArray().toString().contains("user2")); + } + + @Test + public void putObject_CanReserializeWithSegment() throws Exception { + // Create a segment JSON + String segmentJson = "{\n" + + " \"key\": \"my-segment\",\n" + + " \"version\": 5,\n" + + " \"included\": [\"alice\", \"bob\"],\n" + + " \"salt\": \"segment-salt\",\n" + + " \"deleted\": false\n" + + "}"; + + JsonElement segmentElement = gsonInstance().fromJson(segmentJson, JsonElement.class); + PutObject putObject = new PutObject(25, "segment", "my-segment", segmentElement); + + String serialized = gsonInstance().toJson(putObject); + PutObject deserialized = PutObject.parse(new JsonReader(new StringReader(serialized))); + + assertEquals(25, deserialized.getVersion()); + assertEquals("segment", deserialized.getKind()); + assertEquals("my-segment", deserialized.getKey()); + + JsonElement deserializedSegmentElement = deserialized.getObject(); + assertEquals("my-segment", deserializedSegmentElement.getAsJsonObject().get("key").getAsString()); + assertEquals(5, deserializedSegmentElement.getAsJsonObject().get("version").getAsInt()); + assertEquals(2, deserializedSegmentElement.getAsJsonObject().get("included").getAsJsonArray().size()); + assertTrue(deserializedSegmentElement.getAsJsonObject().get("included").getAsJsonArray().toString().contains("alice")); + assertTrue(deserializedSegmentElement.getAsJsonObject().get("included").getAsJsonArray().toString().contains("bob")); + assertEquals("segment-salt", deserializedSegmentElement.getAsJsonObject().get("salt").getAsString()); + } + + @Test + public void deleteObject_CanDeserializeAndReserialize() throws Exception { + String json = "{\n" + + " \"version\": 30,\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"deleted-flag\"\n" + + "}"; + + DeleteObject deleteObject = DeleteObject.parse(new JsonReader(new StringReader(json))); + + assertNotNull(deleteObject); + assertEquals(30, deleteObject.getVersion()); + assertEquals("flag", deleteObject.getKind()); + assertEquals("deleted-flag", deleteObject.getKey()); + + // Reserialize + String reserialized = gsonInstance().toJson(deleteObject); + DeleteObject deserialized2 = DeleteObject.parse(new JsonReader(new StringReader(reserialized))); + assertEquals(30, deserialized2.getVersion()); + assertEquals("flag", deserialized2.getKind()); + assertEquals("deleted-flag", deserialized2.getKey()); + } + + @Test + public void deleteObject_CanDeserializeSegment() throws Exception { + String json = "{\n" + + " \"version\": 12,\n" + + " \"kind\": \"segment\",\n" + + " \"key\": \"removed-segment\"\n" + + "}"; + + DeleteObject deleteObject = DeleteObject.parse(new JsonReader(new StringReader(json))); + + assertEquals(12, deleteObject.getVersion()); + assertEquals("segment", deleteObject.getKind()); + assertEquals("removed-segment", deleteObject.getKey()); + } + + @Test + public void payloadTransferred_CanDeserializeAndReserialize() throws Exception { + String json = "{\n" + + " \"state\": \"(p:ABC123:42)\",\n" + + " \"version\": 42\n" + + "}"; + + PayloadTransferred payloadTransferred = PayloadTransferred.parse(new JsonReader(new StringReader(json))); + + assertNotNull(payloadTransferred); + assertEquals("(p:ABC123:42)", payloadTransferred.getState()); + assertEquals(42, payloadTransferred.getVersion()); + + // Reserialize + String reserialized = gsonInstance().toJson(payloadTransferred); + PayloadTransferred deserialized2 = PayloadTransferred.parse(new JsonReader(new StringReader(reserialized))); + assertEquals("(p:ABC123:42)", deserialized2.getState()); + assertEquals(42, deserialized2.getVersion()); + } + + @Test + public void error_CanDeserializeAndReserialize() throws Exception { + String json = "{\n" + + " \"id\": \"error-123\",\n" + + " \"reason\": \"Something went wrong\"\n" + + "}"; + + Error error = Error.parse(new JsonReader(new StringReader(json))); + + assertNotNull(error); + assertEquals("error-123", error.getId()); + assertEquals("Something went wrong", error.getReason()); + + // Reserialize + String reserialized = gsonInstance().toJson(error); + Error deserialized2 = Error.parse(new JsonReader(new StringReader(reserialized))); + assertEquals("error-123", deserialized2.getId()); + assertEquals("Something went wrong", deserialized2.getReason()); + } + + @Test + public void goodbye_CanDeserializeAndReserialize() throws Exception { + String json = "{\n" + + " \"reason\": \"Server is shutting down\"\n" + + "}"; + + Goodbye goodbye = Goodbye.parse(new JsonReader(new StringReader(json))); + + assertNotNull(goodbye); + assertEquals("Server is shutting down", goodbye.getReason()); + + // Reserialize + String reserialized = gsonInstance().toJson(goodbye); + Goodbye deserialized2 = Goodbye.parse(new JsonReader(new StringReader(reserialized))); + assertEquals("Server is shutting down", deserialized2.getReason()); + } + + @Test + public void fDv2PollEvent_CanDeserializeServerIntent() throws Exception { + String json = "{\n" + + " \"event\": \"server-intent\",\n" + + " \"data\": {\n" + + " \"payloads\": [\n" + + " {\n" + + " \"id\": \"evt-123\",\n" + + " \"target\": 50,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }\n" + + " ]\n" + + " }\n" + + "}"; + + FDv2Event pollEvent = FDv2Event.parse(new JsonReader(new StringReader(json))); + + assertNotNull(pollEvent); + assertEquals("server-intent", pollEvent.getEventType()); + + ServerIntent serverIntent = pollEvent.asServerIntent(); + assertNotNull(serverIntent); + assertEquals(1, serverIntent.getPayloads().size()); + assertEquals("evt-123", serverIntent.getPayloads().get(0).getId()); + assertEquals(50, serverIntent.getPayloads().get(0).getTarget()); + } + + @Test + public void fDv2PollEvent_CanDeserializePutObject() throws Exception { + String json = "{\n" + + " \"event\": \"put-object\",\n" + + " \"data\": {\n" + + " \"version\": 100,\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"event-flag\",\n" + + " \"object\": {\n" + + " \"key\": \"event-flag\",\n" + + " \"version\": 1,\n" + + " \"on\": false,\n" + + " \"fallthrough\": { \"variation\": 1 },\n" + + " \"offVariation\": 1,\n" + + " \"variations\": [\"A\", \"B\", \"C\"],\n" + + " \"salt\": \"evt-salt\",\n" + + " \"trackEvents\": false,\n" + + " \"trackEventsFallthrough\": false,\n" + + " \"debugEventsUntilDate\": null,\n" + + " \"clientSide\": false,\n" + + " \"deleted\": false\n" + + " }\n" + + " }\n" + + "}"; + + FDv2Event pollEvent = FDv2Event.parse(new JsonReader(new StringReader(json))); + + assertNotNull(pollEvent); + assertEquals("put-object", pollEvent.getEventType()); + + PutObject putObject = pollEvent.asPutObject(); + assertNotNull(putObject); + assertEquals(100, putObject.getVersion()); + assertEquals("flag", putObject.getKind()); + assertEquals("event-flag", putObject.getKey()); + + JsonElement flagElement = putObject.getObject(); + assertEquals("event-flag", flagElement.getAsJsonObject().get("key").getAsString()); + assertTrue(!flagElement.getAsJsonObject().get("on").getAsBoolean()); + assertEquals(3, flagElement.getAsJsonObject().get("variations").getAsJsonArray().size()); + } + + @Test + public void fDv2PollEvent_CanDeserializeDeleteObject() throws Exception { + String json = "{\n" + + " \"event\": \"delete-object\",\n" + + " \"data\": {\n" + + " \"version\": 99,\n" + + " \"kind\": \"segment\",\n" + + " \"key\": \"old-segment\"\n" + + " }\n" + + "}"; + + FDv2Event pollEvent = FDv2Event.parse(new JsonReader(new StringReader(json))); + + assertEquals("delete-object", pollEvent.getEventType()); + + DeleteObject deleteObject = pollEvent.asDeleteObject(); + assertEquals(99, deleteObject.getVersion()); + assertEquals("segment", deleteObject.getKind()); + assertEquals("old-segment", deleteObject.getKey()); + } + + @Test + public void fDv2PollEvent_CanDeserializePayloadTransferred() throws Exception { + String json = "{\n" + + " \"event\": \"payload-transferred\",\n" + + " \"data\": {\n" + + " \"state\": \"(p:XYZ789:100)\",\n" + + " \"version\": 100\n" + + " }\n" + + "}"; + + FDv2Event pollEvent = FDv2Event.parse(new JsonReader(new StringReader(json))); + + assertEquals("payload-transferred", pollEvent.getEventType()); + + PayloadTransferred payloadTransferred = pollEvent.asPayloadTransferred(); + assertEquals("(p:XYZ789:100)", payloadTransferred.getState()); + assertEquals(100, payloadTransferred.getVersion()); + } + + @Test(expected = SerializationException.class) + public void serverIntent_ThrowsWhenPayloadsFieldMissing() throws Exception { + String json = "{}"; + ServerIntent.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void serverIntent_ThrowsWhenPayloadIdFieldMissing() throws Exception { + String json = "{\n" + + " \"payloads\": [\n" + + " {\n" + + " \"target\": 42,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }\n" + + " ]\n" + + "}"; + ServerIntent.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void serverIntent_ThrowsWhenPayloadTargetFieldMissing() throws Exception { + String json = "{\n" + + " \"payloads\": [\n" + + " {\n" + + " \"id\": \"payload-123\",\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }\n" + + " ]\n" + + "}"; + ServerIntent.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void serverIntent_ThrowsWhenPayloadIntentCodeFieldMissing() throws Exception { + String json = "{\n" + + " \"payloads\": [\n" + + " {\n" + + " \"id\": \"payload-123\",\n" + + " \"target\": 42,\n" + + " \"reason\": \"payload-missing\"\n" + + " }\n" + + " ]\n" + + "}"; + ServerIntent.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void serverIntent_ThrowsWhenPayloadReasonFieldMissing() throws Exception { + String json = "{\n" + + " \"payloads\": [\n" + + " {\n" + + " \"id\": \"payload-123\",\n" + + " \"target\": 42,\n" + + " \"intentCode\": \"xfer-full\"\n" + + " }\n" + + " ]\n" + + "}"; + ServerIntent.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void putObject_ThrowsWhenVersionFieldMissing() throws Exception { + String json = "{\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"test-flag\",\n" + + " \"object\": {}\n" + + "}"; + PutObject.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void putObject_ThrowsWhenKindFieldMissing() throws Exception { + String json = "{\n" + + " \"version\": 10,\n" + + " \"key\": \"test-flag\",\n" + + " \"object\": {}\n" + + "}"; + PutObject.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void putObject_ThrowsWhenKeyFieldMissing() throws Exception { + String json = "{\n" + + " \"version\": 10,\n" + + " \"kind\": \"flag\",\n" + + " \"object\": {}\n" + + "}"; + PutObject.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void putObject_ThrowsWhenObjectFieldMissing() throws Exception { + String json = "{\n" + + " \"version\": 10,\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"test-flag\"\n" + + "}"; + PutObject.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void deleteObject_ThrowsWhenVersionFieldMissing() throws Exception { + String json = "{\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"test-flag\"\n" + + "}"; + DeleteObject.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void deleteObject_ThrowsWhenKindFieldMissing() throws Exception { + String json = "{\n" + + " \"version\": 30,\n" + + " \"key\": \"test-flag\"\n" + + "}"; + DeleteObject.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void deleteObject_ThrowsWhenKeyFieldMissing() throws Exception { + String json = "{\n" + + " \"version\": 30,\n" + + " \"kind\": \"flag\"\n" + + "}"; + DeleteObject.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void payloadTransferred_ThrowsWhenStateFieldMissing() throws Exception { + String json = "{\n" + + " \"version\": 42\n" + + "}"; + PayloadTransferred.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void payloadTransferred_ThrowsWhenVersionFieldMissing() throws Exception { + String json = "{\n" + + " \"state\": \"(p:ABC123:42)\"\n" + + "}"; + PayloadTransferred.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void error_ThrowsWhenReasonFieldMissing() throws Exception { + String json = "{\n" + + " \"id\": \"error-123\"\n" + + "}"; + Error.parse(new JsonReader(new StringReader(json))); + } + + @Test + public void goodbye_CanDeserializeWithoutReason() throws Exception { + // Goodbye has no required fields, so an empty object should be valid + String json = "{}"; + Goodbye goodbye = Goodbye.parse(new JsonReader(new StringReader(json))); + assertNotNull(goodbye); + assertNull(goodbye.getReason()); + } + + @Test(expected = SerializationException.class) + public void fDv2PollEvent_ThrowsWhenEventFieldMissing() throws Exception { + String json = "{\n" + + " \"data\": {\n" + + " \"state\": \"(p:XYZ:100)\",\n" + + " \"version\": 100\n" + + " }\n" + + "}"; + FDv2Event.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = SerializationException.class) + public void fDv2PollEvent_ThrowsWhenDataFieldMissing() throws Exception { + String json = "{\n" + + " \"event\": \"payload-transferred\"\n" + + "}"; + FDv2Event.parse(new JsonReader(new StringReader(json))); + } + + @Test(expected = NullPointerException.class) + public void serverIntent_ThrowsArgumentNullExceptionWhenPayloadsIsNull() { + new ServerIntent(null); + } + + // Note: ServerIntentPayload constructor is package-private, so we can't test null checks directly. + // The null checks are tested indirectly through the parsing logic in the tests above. + + @Test(expected = NullPointerException.class) + public void putObject_ThrowsArgumentNullExceptionWhenKindIsNull() { + JsonElement emptyObject = gsonInstance().fromJson("{}", JsonElement.class); + new PutObject(1, null, "key", emptyObject); + } + + @Test(expected = NullPointerException.class) + public void putObject_ThrowsArgumentNullExceptionWhenKeyIsNull() { + JsonElement emptyObject = gsonInstance().fromJson("{}", JsonElement.class); + new PutObject(1, "flag", null, emptyObject); + } + + @Test(expected = NullPointerException.class) + public void deleteObject_ThrowsArgumentNullExceptionWhenKindIsNull() { + new DeleteObject(1, null, "key"); + } + + @Test(expected = NullPointerException.class) + public void deleteObject_ThrowsArgumentNullExceptionWhenKeyIsNull() { + new DeleteObject(1, "flag", null); + } + + @Test(expected = NullPointerException.class) + public void payloadTransferred_ThrowsArgumentNullExceptionWhenStateIsNull() { + new PayloadTransferred(null, 42); + } + + @Test(expected = NullPointerException.class) + public void error_ThrowsArgumentNullExceptionWhenReasonIsNull() { + new Error("id", null); + } + + @Test + public void fullPollingResponse_CanDeserialize() throws Exception { + String json = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"server-intent\",\n" + + " \"data\": {\n" + + " \"payloads\": [{\n" + + " \"id\": \"poll-payload-1\",\n" + + " \"target\": 200,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }]\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"put-object\",\n" + + " \"data\": {\n" + + " \"version\": 150,\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"flag-one\",\n" + + " \"object\": {\n" + + " \"key\": \"flag-one\",\n" + + " \"version\": 1,\n" + + " \"on\": true,\n" + + " \"fallthrough\": { \"variation\": 0 },\n" + + " \"offVariation\": 1,\n" + + " \"variations\": [true, false],\n" + + " \"salt\": \"flag-one-salt\",\n" + + " \"trackEvents\": false,\n" + + " \"trackEventsFallthrough\": false,\n" + + " \"debugEventsUntilDate\": null,\n" + + " \"clientSide\": true,\n" + + " \"deleted\": false\n" + + " }\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"put-object\",\n" + + " \"data\": {\n" + + " \"version\": 160,\n" + + " \"kind\": \"segment\",\n" + + " \"key\": \"segment-one\",\n" + + " \"object\": {\n" + + " \"key\": \"segment-one\",\n" + + " \"version\": 2,\n" + + " \"included\": [\"user-a\", \"user-b\"],\n" + + " \"salt\": \"seg-salt\",\n" + + " \"deleted\": false\n" + + " }\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"delete-object\",\n" + + " \"data\": {\n" + + " \"version\": 170,\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"old-flag\"\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"payload-transferred\",\n" + + " \"data\": {\n" + + " \"state\": \"(p:poll-payload-1:200)\",\n" + + " \"version\": 200\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + // Parse the polling response + List eventsList = FDv2Event.parseEventsArray(json); + + assertNotNull(eventsList); + assertEquals(5, eventsList.size()); + + // Verify server-intent + assertEquals("server-intent", eventsList.get(0).getEventType()); + ServerIntent serverIntent = eventsList.get(0).asServerIntent(); + assertEquals("poll-payload-1", serverIntent.getPayloads().get(0).getId()); + assertEquals(200, serverIntent.getPayloads().get(0).getTarget()); + + // Verify first put-object (flag) + assertEquals("put-object", eventsList.get(1).getEventType()); + PutObject putFlag = eventsList.get(1).asPutObject(); + assertEquals("flag", putFlag.getKind()); + assertEquals("flag-one", putFlag.getKey()); + JsonElement flagElement = putFlag.getObject(); + assertEquals("flag-one", flagElement.getAsJsonObject().get("key").getAsString()); + assertTrue(flagElement.getAsJsonObject().get("on").getAsBoolean()); + + // Verify second put-object (segment) + assertEquals("put-object", eventsList.get(2).getEventType()); + PutObject putSegment = eventsList.get(2).asPutObject(); + assertEquals("segment", putSegment.getKind()); + assertEquals("segment-one", putSegment.getKey()); + JsonElement segmentElement = putSegment.getObject(); + assertEquals("segment-one", segmentElement.getAsJsonObject().get("key").getAsString()); + assertEquals(2, segmentElement.getAsJsonObject().get("included").getAsJsonArray().size()); + + // Verify delete-object + assertEquals("delete-object", eventsList.get(3).getEventType()); + DeleteObject deleteObj = eventsList.get(3).asDeleteObject(); + assertEquals("flag", deleteObj.getKind()); + assertEquals("old-flag", deleteObj.getKey()); + + // Verify payload-transferred + assertEquals("payload-transferred", eventsList.get(4).getEventType()); + PayloadTransferred transferred = eventsList.get(4).asPayloadTransferred(); + assertEquals("(p:poll-payload-1:200)", transferred.getState()); + assertEquals(200, transferred.getVersion()); + } + + @Test(expected = SerializationException.class) + public void deserializeEventsArray_ThrowsWhenEventsPropertyMissing() throws Exception { + String json = "{}"; + FDv2Event.parseEventsArray(json); + } + + @Test(expected = SerializationException.class) + public void deserializeEventsArray_ThrowsWhenEventIsNull() throws Exception { + String json = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"server-intent\",\n" + + " \"data\": {\n" + + " \"payloads\": [{\n" + + " \"id\": \"payload-1\",\n" + + " \"target\": 100,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }]\n" + + " }\n" + + " },\n" + + " null,\n" + + " {\n" + + " \"event\": \"heartbeat\",\n" + + " \"data\": {}\n" + + " }\n" + + " ]\n" + + "}"; + + FDv2Event.parseEventsArray(json); + } + + @Test + public void deserializeEventsArray_CanDeserializeEmptyArray() throws Exception { + String json = "{\n" + + " \"events\": []\n" + + "}"; + + List events = FDv2Event.parseEventsArray(json); + assertNotNull(events); + assertTrue(events.isEmpty()); + } + + @Test + public void deserializeEventsArray_CanDeserializeValidEventsArray() throws Exception { + String json = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"server-intent\",\n" + + " \"data\": {\n" + + " \"payloads\": [{\n" + + " \"id\": \"payload-1\",\n" + + " \"target\": 100,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }]\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"put-object\",\n" + + " \"data\": {\n" + + " \"version\": 150,\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"test-flag\",\n" + + " \"object\": {\n" + + " \"key\": \"test-flag\",\n" + + " \"version\": 1,\n" + + " \"on\": true,\n" + + " \"fallthrough\": { \"variation\": 0 },\n" + + " \"offVariation\": 1,\n" + + " \"variations\": [true, false],\n" + + " \"salt\": \"test-salt\",\n" + + " \"trackEvents\": false,\n" + + " \"trackEventsFallthrough\": false,\n" + + " \"debugEventsUntilDate\": null,\n" + + " \"clientSide\": false,\n" + + " \"deleted\": false\n" + + " }\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"payload-transferred\",\n" + + " \"data\": {\n" + + " \"state\": \"(p:payload-1:100)\",\n" + + " \"version\": 100\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + List events = FDv2Event.parseEventsArray(json); + + assertNotNull(events); + assertEquals(3, events.size()); + + assertEquals("server-intent", events.get(0).getEventType()); + ServerIntent serverIntent = events.get(0).asServerIntent(); + assertEquals("payload-1", serverIntent.getPayloads().get(0).getId()); + + assertEquals("put-object", events.get(1).getEventType()); + PutObject putObject = events.get(1).asPutObject(); + assertEquals("test-flag", putObject.getKey()); + + assertEquals("payload-transferred", events.get(2).getEventType()); + PayloadTransferred transferred = events.get(2).asPayloadTransferred(); + assertEquals(100, transferred.getVersion()); + } +} + diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandlerTest.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandlerTest.java new file mode 100644 index 00000000..ae32c71b --- /dev/null +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandlerTest.java @@ -0,0 +1,1066 @@ +package com.launchdarkly.sdk.internal.fdv2.sources; + +import com.google.gson.JsonElement; +import com.launchdarkly.sdk.internal.BaseInternalTest; +import com.launchdarkly.sdk.internal.fdv2.payloads.DeleteObject; +import com.launchdarkly.sdk.internal.fdv2.payloads.Error; +import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event; +import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.FDv2EventTypeMismatchException; +import com.launchdarkly.sdk.internal.fdv2.payloads.Goodbye; +import com.launchdarkly.sdk.internal.fdv2.payloads.IntentCode; +import com.launchdarkly.sdk.internal.fdv2.payloads.PayloadTransferred; +import com.launchdarkly.sdk.internal.fdv2.payloads.PutObject; +import com.launchdarkly.sdk.internal.fdv2.payloads.ServerIntent; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +public class FDv2ProtocolHandlerTest extends BaseInternalTest { + + private static FDv2Event createServerIntentEvent(IntentCode intentCode, String payloadId, int target, String reason) { + List payloads = Collections.singletonList( + new ServerIntent.ServerIntentPayload(payloadId, target, intentCode, reason)); + ServerIntent intent = new ServerIntent(payloads); + String json = gsonInstance().toJson(intent); + JsonElement data = gsonInstance().fromJson(json, JsonElement.class); + return new FDv2Event(FDv2EventTypes.SERVER_INTENT, data); + } + + private static FDv2Event createServerIntentEvent(IntentCode intentCode) { + return createServerIntentEvent(intentCode, "test-payload", 1, "test-reason"); + } + + private static FDv2Event createPutObjectEvent(String kind, String key, int version, String jsonStr) { + JsonElement objectElement = gsonInstance().fromJson(jsonStr, JsonElement.class); + PutObject putObj = new PutObject(version, kind, key, objectElement); + String json = gsonInstance().toJson(putObj); + JsonElement data = gsonInstance().fromJson(json, JsonElement.class); + return new FDv2Event(FDv2EventTypes.PUT_OBJECT, data); + } + + private static FDv2Event createPutObjectEvent(String kind, String key, int version) { + return createPutObjectEvent(kind, key, version, "{}"); + } + + private static FDv2Event createDeleteObjectEvent(String kind, String key, int version) { + DeleteObject deleteObj = new DeleteObject(version, kind, key); + String json = gsonInstance().toJson(deleteObj); + JsonElement data = gsonInstance().fromJson(json, JsonElement.class); + return new FDv2Event(FDv2EventTypes.DELETE_OBJECT, data); + } + + private static FDv2Event createPayloadTransferredEvent(String state, int version) { + PayloadTransferred transferred = new PayloadTransferred(state, version); + String json = gsonInstance().toJson(transferred); + JsonElement data = gsonInstance().fromJson(json, JsonElement.class); + return new FDv2Event(FDv2EventTypes.PAYLOAD_TRANSFERRED, data); + } + + private static FDv2Event createErrorEvent(String id, String reason) { + Error error = new Error(id, reason); + String json = gsonInstance().toJson(error); + JsonElement data = gsonInstance().fromJson(json, JsonElement.class); + return new FDv2Event(FDv2EventTypes.ERROR, data); + } + + private static FDv2Event createGoodbyeEvent(String reason) { + Goodbye goodbye = new Goodbye(reason); + String json = gsonInstance().toJson(goodbye); + JsonElement data = gsonInstance().fromJson(json, JsonElement.class); + return new FDv2Event(FDv2EventTypes.GOODBYE, data); + } + + private static FDv2Event createHeartbeatEvent() { + JsonElement data = gsonInstance().fromJson("{}", JsonElement.class); + return new FDv2Event(FDv2EventTypes.HEARTBEAT, data); + } + + // Section 2.2.2: SDK has up to date saved payload + + /** + * Tests the scenario from section 2.2.2 where the SDK has an up-to-date payload. + * The server responds with intentCode: none indicating no changes are needed. + */ + @Test + public void serverIntent_WithIntentCodeNone_ReturnsChangesetImmediately() { + // Section 2.2.2: SDK has up to date saved payload + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + FDv2Event evt = createServerIntentEvent(IntentCode.NONE, "payload-123", 52, "up-to-date"); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(evt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionChangeset); + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.NONE, changesetAction.getChangeset().getType()); + assertTrue(changesetAction.getChangeset().getChanges().isEmpty()); + } + + // Section 2.1.1 & 2.2.1: SDK has no saved payload (Full Transfer) + + /** + * Tests the scenario from sections 2.1.1 and 2.2.1 where the SDK has no saved payload. + * The server responds with intentCode: xfer-full and sends a complete payload. + */ + @Test + public void fullTransfer_AccumulatesChangesAndEmitsOnPayloadTransferred() { + // Section 2.1.1 & 2.2.1: SDK has no saved payload and continues to get changes + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Server-intent with xfer-full + FDv2Event intentEvt = createServerIntentEvent(IntentCode.TRANSFER_FULL, "payload-123", 52, "payload-missing"); + FDv2ProtocolHandler.IFDv2ProtocolAction intentAction = handler.handleEvent(intentEvt); + assertTrue(intentAction instanceof FDv2ProtocolHandler.FDv2ActionNone); + + // Put some objects + FDv2Event put1 = createPutObjectEvent("flag", "flag-123", 12); + FDv2ProtocolHandler.IFDv2ProtocolAction put1Action = handler.handleEvent(put1); + assertTrue(put1Action instanceof FDv2ProtocolHandler.FDv2ActionNone); + + FDv2Event put2 = createPutObjectEvent("flag", "flag-abc", 12); + FDv2ProtocolHandler.IFDv2ProtocolAction put2Action = handler.handleEvent(put2); + assertTrue(put2Action instanceof FDv2ProtocolHandler.FDv2ActionNone); + + // Payload-transferred finalizes the changeset + FDv2Event transferredEvt = createPayloadTransferredEvent("(p:payload-123:52)", 52); + FDv2ProtocolHandler.IFDv2ProtocolAction transferredAction = handler.handleEvent(transferredEvt); + + assertTrue(transferredAction instanceof FDv2ProtocolHandler.FDv2ActionChangeset); + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) transferredAction; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.FULL, changesetAction.getChangeset().getType()); + assertEquals(2, changesetAction.getChangeset().getChanges().size()); + assertEquals("flag-123", changesetAction.getChangeset().getChanges().get(0).getKey()); + assertEquals("flag-abc", changesetAction.getChangeset().getChanges().get(1).getKey()); + assertEquals("(p:payload-123:52)", changesetAction.getChangeset().getSelector().getState()); + assertEquals(52, changesetAction.getChangeset().getSelector().getVersion()); + } + + /** + * Tests that a full transfer properly replaces any partial state. + * Requirement 3.3.1: SDK must prepare to fully replace its local payload representation. + */ + @Test + public void fullTransfer_ReplacesPartialState() { + // Requirement 3.3.1: Prepare to fully replace local payload representation + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Start with an intent to transfer changes + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_CHANGES, "p1", 1, "stale")); + handler.handleEvent(createPutObjectEvent("flag", "flag-1", 1)); + + // Now receive xfer-full - should replace/reset + FDv2Event fullIntent = createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 2, "outdated"); + handler.handleEvent(fullIntent); + + // Send new full payload + handler.handleEvent(createPutObjectEvent("flag", "flag-2", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:2)", 2)); + + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.FULL, changesetAction.getChangeset().getType()); + // Should only have flag-2, not flag-1 + assertEquals(1, changesetAction.getChangeset().getChanges().size()); + assertEquals("flag-2", changesetAction.getChangeset().getChanges().get(0).getKey()); + } + + // Section 2.1.2 & 2.2.3: SDK has stale saved payload (Incremental Changes) + + /** + * Tests the scenario from sections 2.1.2 and 2.2.3 where the SDK has a stale payload. + * The server responds with intentCode: xfer-changes and sends incremental updates. + */ + @Test + public void incrementalTransfer_AccumulatesChangesAndEmitsOnPayloadTransferred() { + // Section 2.1.2 & 2.2.3: SDK has stale saved payload + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Server-intent with xfer-changes + FDv2Event intentEvt = createServerIntentEvent(IntentCode.TRANSFER_CHANGES, "payload-123", 52, "stale"); + FDv2ProtocolHandler.IFDv2ProtocolAction intentAction = handler.handleEvent(intentEvt); + assertTrue(intentAction instanceof FDv2ProtocolHandler.FDv2ActionNone); + + // Put and delete objects + FDv2Event put1 = createPutObjectEvent("flag", "flag-cat", 13); + handler.handleEvent(put1); + + FDv2Event put2 = createPutObjectEvent("flag", "flag-dog", 13); + handler.handleEvent(put2); + + FDv2Event delete1 = createDeleteObjectEvent("flag", "flag-bat", 13); + handler.handleEvent(delete1); + + FDv2Event put3 = createPutObjectEvent("flag", "flag-cow", 14); + handler.handleEvent(put3); + + // Payload-transferred finalizes the changeset + FDv2Event transferredEvt = createPayloadTransferredEvent("(p:payload-123:52)", 52); + FDv2ProtocolHandler.IFDv2ProtocolAction transferredAction = handler.handleEvent(transferredEvt); + + assertTrue(transferredAction instanceof FDv2ProtocolHandler.FDv2ActionChangeset); + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) transferredAction; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.PARTIAL, changesetAction.getChangeset().getType()); + assertEquals(4, changesetAction.getChangeset().getChanges().size()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.PUT, changesetAction.getChangeset().getChanges().get(0).getType()); + assertEquals("flag-cat", changesetAction.getChangeset().getChanges().get(0).getKey()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.PUT, changesetAction.getChangeset().getChanges().get(1).getType()); + assertEquals("flag-dog", changesetAction.getChangeset().getChanges().get(1).getKey()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.DELETE, changesetAction.getChangeset().getChanges().get(2).getType()); + assertEquals("flag-bat", changesetAction.getChangeset().getChanges().get(2).getKey()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.PUT, changesetAction.getChangeset().getChanges().get(3).getType()); + assertEquals("flag-cow", changesetAction.getChangeset().getChanges().get(3).getKey()); + } + + // Requirement 3.3.2: Payload State Validity + + /** + * Requirement 3.3.2: SDK must not consider its local payload state X as valid until + * receiving the payload-transferred event for the corresponding payload state X. + */ + @Test + public void payloadTransferred_OnlyEmitsChangesetAfterReceivingEvent() { + // Requirement 3.3.2: Only consider payload valid after payload-transferred + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + + // Accumulate changes - should not emit changeset yet + FDv2ProtocolHandler.IFDv2ProtocolAction action1 = handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + assertTrue(action1 instanceof FDv2ProtocolHandler.FDv2ActionNone); + + FDv2ProtocolHandler.IFDv2ProtocolAction action2 = handler.handleEvent(createPutObjectEvent("flag", "f2", 1)); + assertTrue(action2 instanceof FDv2ProtocolHandler.FDv2ActionNone); + + // Only after payload-transferred should we get a changeset + FDv2ProtocolHandler.IFDv2ProtocolAction action3 = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + assertTrue(action3 instanceof FDv2ProtocolHandler.FDv2ActionChangeset); + } + + /** + * Tests that payload-transferred event returns protocol error if received without prior server-intent. + */ + @Test + public void payloadTransferred_WithoutServerIntent_ReturnsProtocolError() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Attempt to send payload-transferred without server-intent + FDv2Event transferredEvt = createPayloadTransferredEvent("(p:payload-123:52)", 52); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(transferredEvt); + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.PROTOCOL_ERROR, internalError.getErrorType()); + assertTrue(internalError.getMessage().contains("without an intent")); + } + + // Requirement 3.3.7 & 3.3.8: Error Handling + + /** + * Requirement 3.3.7: SDK must discard partially transferred data when an error event is encountered. + * Requirement 3.3.8: SDK should stay connected after receiving an application level error event. + */ + @Test + public void error_DiscardsPartiallyTransferredData() { + // Requirements 3.3.7 & 3.3.8: Discard partial data on error, stay connected + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + handler.handleEvent(createPutObjectEvent("flag", "f2", 1)); + + // Error occurs - partial data should be discarded + FDv2Event errorEvt = createErrorEvent("p1", "Something went wrong"); + FDv2ProtocolHandler.IFDv2ProtocolAction errorAction = handler.handleEvent(errorEvt); + + assertTrue(errorAction instanceof FDv2ProtocolHandler.FDv2ActionError); + FDv2ProtocolHandler.FDv2ActionError errorActionTyped = (FDv2ProtocolHandler.FDv2ActionError) errorAction; + assertEquals("p1", errorActionTyped.getId()); + assertEquals("Something went wrong", errorActionTyped.getReason()); + + // Server recovers and resends + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "retry")); + handler.handleEvent(createPutObjectEvent("flag", "f3", 1)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + + // Should only have f3, not f1 or f2 + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + assertEquals(1, changesetAction.getChangeset().getChanges().size()); + assertEquals("f3", changesetAction.getChangeset().getChanges().get(0).getKey()); + } + + /** + * Tests that error maintains the current state (Full vs. Changes) after clearing partial data. + */ + @Test + public void error_MaintainsCurrentState() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Start with an intent to transfer changes + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_CHANGES, "p1", 1, "stale")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + + // Error occurs + handler.handleEvent(createErrorEvent("p1", "error")); + + // Continue receiving changes (no new server-intent) + handler.handleEvent(createPutObjectEvent("flag", "f2", 1)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + // Should still be Partial (the state is maintained). + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.PARTIAL, changesetAction.getChangeset().getType()); + assertEquals(1, changesetAction.getChangeset().getChanges().size()); + assertEquals("f2", changesetAction.getChangeset().getChanges().get(0).getKey()); + } + + // Requirement 3.3.5: Goodbye Handling + + /** + * Requirement 3.3.5: SDK must log a message at the info level when a goodbye event is encountered. + * The message must include the reason. + */ + @Test + public void goodbye_ReturnsGoodbyeActionWithReason() { + // Requirement 3.3.5: Log goodbye with reason + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + FDv2Event goodbyeEvt = createGoodbyeEvent("Server is shutting down"); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(goodbyeEvt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionGoodbye); + FDv2ProtocolHandler.FDv2ActionGoodbye goodbyeAction = (FDv2ProtocolHandler.FDv2ActionGoodbye) action; + assertEquals("Server is shutting down", goodbyeAction.getReason()); + } + + // Requirement 3.3.9: Heartbeat Handling + + /** + * Requirement 3.3.9: SDK must silently handle/ignore heartbeat events. + */ + @Test + public void heartbeat_IsSilentlyIgnored() { + // Requirement 3.3.9: Silently ignore heartbeat events + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + FDv2Event heartbeatEvt = createHeartbeatEvent(); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(heartbeatEvt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionNone); + } + + // Requirement 3.4.2: Multiple Payloads Handling + + /** + * Requirement 3.4.2: SDK must ignore all but the first payload of the server-intent event + * and must not crash/error when receiving messages that contain multiple payloads. + */ + @Test + public void serverIntent_WithMultiplePayloads_UsesOnlyFirstPayload() { + // Requirement 3.4.2: Ignore all but the first payload + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + List payloads = new ArrayList<>(); + payloads.add(new ServerIntent.ServerIntentPayload("payload-1", 10, IntentCode.TRANSFER_CHANGES, "stale")); + payloads.add(new ServerIntent.ServerIntentPayload("payload-2", 20, IntentCode.NONE, "up-to-date")); + ServerIntent intent = new ServerIntent(payloads); + String json = gsonInstance().toJson(intent); + JsonElement data = gsonInstance().fromJson(json, JsonElement.class); + FDv2Event evt = new FDv2Event(FDv2EventTypes.SERVER_INTENT, data); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(evt); + + // Should return None because the first payload is TransferChanges (not None) + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionNone); + + // Verify we're in Changes state by sending changes + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = + (FDv2ProtocolHandler.FDv2ActionChangeset) handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.PARTIAL, changesetAction.getChangeset().getType()); + } + + // Error Type Handling + + /** + * Tests that unknown event types are handled gracefully with UnknownEvent error type. + */ + @Test + public void unknownEventType_ReturnsUnknownEventError() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + JsonElement data = gsonInstance().fromJson("{}", JsonElement.class); + FDv2Event unknownEvt = new FDv2Event("unknown-event-type", data); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(unknownEvt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.UNKNOWN_EVENT, internalError.getErrorType()); + assertTrue(internalError.getMessage().contains("unknown-event-type")); + } + + /** + * Tests that server-intent with empty payload list returns MissingPayload error type. + */ + @Test + public void serverIntent_WithEmptyPayloadList_ReturnsMissingPayloadError() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + ServerIntent intent = new ServerIntent(Collections.emptyList()); + String json = gsonInstance().toJson(intent); + JsonElement data = gsonInstance().fromJson(json, JsonElement.class); + FDv2Event evt = new FDv2Event(FDv2EventTypes.SERVER_INTENT, data); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(evt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.MISSING_PAYLOAD, internalError.getErrorType()); + assertTrue(internalError.getMessage().contains("No payload present")); + } + + /** + * Tests that payload-transferred without server-intent returns ProtocolError error type. + */ + @Test + public void payloadTransferred_WithoutServerIntent_ReturnsProtocolErrorType() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + FDv2Event transferredEvt = createPayloadTransferredEvent("(p:payload-123:52)", 52); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(transferredEvt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.PROTOCOL_ERROR, internalError.getErrorType()); + assertTrue(internalError.getMessage().contains("without an intent")); + } + + // State Transitions + + /** + * Tests that after payload-transferred, the handler transitions to Changes state + * to receive subsequent incremental updates. + */ + @Test + public void payloadTransferred_TransitionsToChangesState() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Start with full transfer + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + FDv2ProtocolHandler.IFDv2ProtocolAction action1 = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + + FDv2ChangeSet changeset1 = ((FDv2ProtocolHandler.FDv2ActionChangeset) action1).getChangeset(); + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.FULL, changeset1.getType()); + + // Now send more changes without new server-intent - should be Partial + handler.handleEvent(createPutObjectEvent("flag", "f2", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action2 = handler.handleEvent(createPayloadTransferredEvent("(p:p1:2)", 2)); + + FDv2ChangeSet changeset2 = ((FDv2ProtocolHandler.FDv2ActionChangeset) action2).getChangeset(); + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.PARTIAL, changeset2.getType()); + } + + /** + * Tests that IntentCode.None properly sets the state to Changes. + */ + @Test + public void serverIntent_WithIntentCodeNone_TransitionsToChangesState() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Receive intent with None + handler.handleEvent(createServerIntentEvent(IntentCode.NONE, "p1", 1, "up-to-date")); + + // Now send incremental changes + handler.handleEvent(createPutObjectEvent("flag", "f1", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:2)", 2)); + + FDv2ChangeSet changeset = ((FDv2ProtocolHandler.FDv2ActionChangeset) action).getChangeset(); + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.PARTIAL, changeset.getType()); + } + + // Put and Delete Operations + + /** + * Tests that put-object events correctly accumulate with all required fields. + * Section 3.2: put-object contains payload objects that should be accepted with upsert semantics. + */ + @Test + public void putObject_AccumulatesWithAllFields() { + // Section 3.2: put-object with upsert semantics + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + + String flagData = "{\"key\":\"test-flag\",\"on\":true, \"version\": 314}"; + FDv2Event putEvt = createPutObjectEvent("flag", "test-flag", 42, flagData); + handler.handleEvent(putEvt); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + + FDv2ChangeSet changeset = ((FDv2ProtocolHandler.FDv2ActionChangeset) action).getChangeset(); + assertEquals(1, changeset.getChanges().size()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.PUT, changeset.getChanges().get(0).getType()); + assertEquals("flag", changeset.getChanges().get(0).getKind()); + assertEquals("test-flag", changeset.getChanges().get(0).getKey()); + assertEquals(42, changeset.getChanges().get(0).getVersion()); + assertNotNull(changeset.getChanges().get(0).getObject()); + + // Verify we can access the stored JSON element + JsonElement flagElement = changeset.getChanges().get(0).getObject(); + assertEquals("test-flag", flagElement.getAsJsonObject().get("key").getAsString()); + assertEquals(314, flagElement.getAsJsonObject().get("version").getAsInt()); + assertTrue(flagElement.getAsJsonObject().get("on").getAsBoolean()); + } + + /** + * Tests that delete-object events correctly accumulate. + * Section 3.3: delete-object contains payload objects that should be deleted. + */ + @Test + public void deleteObject_AccumulatesWithAllFields() { + // Section 3.3: delete-object + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_CHANGES, "p1", 1, "stale")); + + FDv2Event deleteEvt = createDeleteObjectEvent("segment", "old-segment", 99); + handler.handleEvent(deleteEvt); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + + FDv2ChangeSet changeset = ((FDv2ProtocolHandler.FDv2ActionChangeset) action).getChangeset(); + assertEquals(1, changeset.getChanges().size()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.DELETE, changeset.getChanges().get(0).getType()); + assertEquals("segment", changeset.getChanges().get(0).getKind()); + assertEquals("old-segment", changeset.getChanges().get(0).getKey()); + assertEquals(99, changeset.getChanges().get(0).getVersion()); + assertNull(changeset.getChanges().get(0).getObject()); + } + + /** + * Tests that put and delete operations can be mixed in a single changeset. + */ + @Test + public void putAndDelete_CanBeMixedInSameChangeset() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_CHANGES, "p1", 1, "stale")); + + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + handler.handleEvent(createDeleteObjectEvent("flag", "f2", 1)); + handler.handleEvent(createPutObjectEvent("segment", "s1", 1)); + handler.handleEvent(createDeleteObjectEvent("segment", "s2", 1)); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + + FDv2ChangeSet changeset = ((FDv2ProtocolHandler.FDv2ActionChangeset) action).getChangeset(); + assertEquals(4, changeset.getChanges().size()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.PUT, changeset.getChanges().get(0).getType()); + assertEquals("f1", changeset.getChanges().get(0).getKey()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.DELETE, changeset.getChanges().get(1).getType()); + assertEquals("f2", changeset.getChanges().get(1).getKey()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.PUT, changeset.getChanges().get(2).getType()); + assertEquals("s1", changeset.getChanges().get(2).getKey()); + assertEquals(FDv2ChangeSet.FDv2ChangeType.DELETE, changeset.getChanges().get(3).getType()); + assertEquals("s2", changeset.getChanges().get(3).getKey()); + } + + // Multiple Transfer Cycles + + /** + * Tests that the handler can process multiple complete transfer cycles. + * Simulates a streaming connection receiving multiple payload updates over time. + */ + @Test + public void multipleTransferCycles_AreHandledCorrectly() { + // Section 2.1.1: "some time later" - multiple transfers + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // First full transfer + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 52, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + handler.handleEvent(createPutObjectEvent("flag", "f2", 1)); + FDv2ProtocolHandler.IFDv2ProtocolAction action1 = handler.handleEvent(createPayloadTransferredEvent("(p:p1:52)", 52)); + + FDv2ChangeSet changeset1 = ((FDv2ProtocolHandler.FDv2ActionChangeset) action1).getChangeset(); + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.FULL, changeset1.getType()); + assertEquals(2, changeset1.getChanges().size()); + + // Second incremental transfer (some time later) + handler.handleEvent(createPutObjectEvent("flag", "f1", 2)); + handler.handleEvent(createDeleteObjectEvent("flag", "f2", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action2 = handler.handleEvent(createPayloadTransferredEvent("(p:p1:53)", 53)); + + FDv2ChangeSet changeset2 = ((FDv2ProtocolHandler.FDv2ActionChangeset) action2).getChangeset(); + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.PARTIAL, changeset2.getType()); + assertEquals(2, changeset2.getChanges().size()); + + // Third incremental transfer + handler.handleEvent(createPutObjectEvent("flag", "f3", 3)); + FDv2ProtocolHandler.IFDv2ProtocolAction action3 = handler.handleEvent(createPayloadTransferredEvent("(p:p1:54)", 54)); + + FDv2ChangeSet changeset3 = ((FDv2ProtocolHandler.FDv2ActionChangeset) action3).getChangeset(); + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.PARTIAL, changeset3.getType()); + assertEquals(1, changeset3.getChanges().size()); + } + + /** + * Tests that receiving a new server-intent during an ongoing transfer properly resets state. + * Per spec: "The SDK may receive multiple server-intent messages with xfer-full within one connection's lifespan." + */ + @Test + public void newServerIntent_DuringTransfer_ResetsState() { + // Requirement 3.3.1: SDK may receive multiple server-intent messages + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Start first transfer + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + + // Receive new server-intent before payload-transferred (e.g., server restarted) + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 2, "reset")); + handler.handleEvent(createPutObjectEvent("flag", "f2", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:2)", 2)); + + FDv2ChangeSet changeset = ((FDv2ProtocolHandler.FDv2ActionChangeset) action).getChangeset(); + // Should only have f2, the first transfer was abandoned + assertEquals(1, changeset.getChanges().size()); + assertEquals("f2", changeset.getChanges().get(0).getKey()); + } + + // Empty Payloads and Edge Cases + + /** + * Tests handling of a transfer with no objects. + */ + @Test + public void transfer_WithNoObjects_EmitsEmptyChangeset() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + // No put or delete events + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + + FDv2ChangeSet changeset = ((FDv2ProtocolHandler.FDv2ActionChangeset) action).getChangeset(); + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.FULL, changeset.getType()); + assertTrue(changeset.getChanges().isEmpty()); + } + + // Selector Verification + + /** + * Tests that the selector is properly populated from payload-transferred event. + */ + @Test + public void payloadTransferred_PopulatesSelector() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "test-payload-id", 42, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:test-payload-id:42)", 42)); + + FDv2ChangeSet changeset = ((FDv2ProtocolHandler.FDv2ActionChangeset) action).getChangeset(); + assertFalse(changeset.getSelector().isEmpty()); + assertEquals("(p:test-payload-id:42)", changeset.getSelector().getState()); + assertEquals(42, changeset.getSelector().getVersion()); + } + + /** + * Tests that ChangeSet.None has an empty selector. + */ + @Test + public void changeSetNone_HasEmptySelector() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createServerIntentEvent(IntentCode.NONE, "p1", 1, "up-to-date")); + + FDv2ChangeSet changeset = ((FDv2ProtocolHandler.FDv2ActionChangeset) action).getChangeset(); + assertTrue(changeset.getSelector().isEmpty()); + } + + // FDv2Event Type Validation + + /** + * Tests that AsServerIntent throws FDv2EventTypeMismatchException when called on a non-server-intent event. + */ + @Test + public void asServerIntent_WithWrongEventType_ThrowsFDv2EventTypeMismatchException() throws Exception { + FDv2Event evt = createPutObjectEvent("flag", "f1", 1); + try { + evt.asServerIntent(); + fail("Expected FDv2EventTypeMismatchException"); + } catch (FDv2EventTypeMismatchException ex) { + assertEquals(FDv2EventTypes.PUT_OBJECT, ex.getActualEventType()); + assertEquals(FDv2EventTypes.SERVER_INTENT, ex.getExpectedEventType()); + } + } + + /** + * Tests that AsPutObject throws FDv2EventTypeMismatchException when called on a non-put-object event. + */ + @Test + public void asPutObject_WithWrongEventType_ThrowsFDv2EventTypeMismatchException() throws Exception { + FDv2Event evt = createServerIntentEvent(IntentCode.NONE); + try { + evt.asPutObject(); + fail("Expected FDv2EventTypeMismatchException"); + } catch (FDv2EventTypeMismatchException ex) { + assertEquals(FDv2EventTypes.SERVER_INTENT, ex.getActualEventType()); + assertEquals(FDv2EventTypes.PUT_OBJECT, ex.getExpectedEventType()); + } + } + + /** + * Tests that AsDeleteObject throws FDv2EventTypeMismatchException when called on a non-delete-object event. + */ + @Test + public void asDeleteObject_WithWrongEventType_ThrowsFDv2EventTypeMismatchException() throws Exception { + FDv2Event evt = createServerIntentEvent(IntentCode.NONE); + try { + evt.asDeleteObject(); + fail("Expected FDv2EventTypeMismatchException"); + } catch (FDv2EventTypeMismatchException ex) { + assertEquals(FDv2EventTypes.SERVER_INTENT, ex.getActualEventType()); + assertEquals(FDv2EventTypes.DELETE_OBJECT, ex.getExpectedEventType()); + } + } + + /** + * Tests that AsPayloadTransferred throws FDv2EventTypeMismatchException when called on a non-payload-transferred event. + */ + @Test + public void asPayloadTransferred_WithWrongEventType_ThrowsFDv2EventTypeMismatchException() throws Exception { + FDv2Event evt = createServerIntentEvent(IntentCode.NONE); + try { + evt.asPayloadTransferred(); + fail("Expected FDv2EventTypeMismatchException"); + } catch (FDv2EventTypeMismatchException ex) { + assertEquals(FDv2EventTypes.SERVER_INTENT, ex.getActualEventType()); + assertEquals(FDv2EventTypes.PAYLOAD_TRANSFERRED, ex.getExpectedEventType()); + } + } + + /** + * Tests that AsError throws FDv2EventTypeMismatchException when called on a non-error event. + */ + @Test + public void asError_WithWrongEventType_ThrowsFDv2EventTypeMismatchException() throws Exception { + FDv2Event evt = createServerIntentEvent(IntentCode.NONE); + try { + evt.asError(); + fail("Expected FDv2EventTypeMismatchException"); + } catch (FDv2EventTypeMismatchException ex) { + assertEquals(FDv2EventTypes.SERVER_INTENT, ex.getActualEventType()); + assertEquals(FDv2EventTypes.ERROR, ex.getExpectedEventType()); + } + } + + /** + * Tests that AsGoodbye throws FDv2EventTypeMismatchException when called on a non-goodbye event. + */ + @Test + public void asGoodbye_WithWrongEventType_ThrowsFDv2EventTypeMismatchException() throws Exception { + FDv2Event evt = createServerIntentEvent(IntentCode.NONE); + try { + evt.asGoodbye(); + fail("Expected FDv2EventTypeMismatchException"); + } catch (FDv2EventTypeMismatchException ex) { + assertEquals(FDv2EventTypes.SERVER_INTENT, ex.getActualEventType()); + assertEquals(FDv2EventTypes.GOODBYE, ex.getExpectedEventType()); + } + } + + // JSON Deserialization Error Handling + + /** + * Tests that HandleEvent returns JsonError when event data is malformed JSON. + */ + @Test + public void handleEvent_WithMalformedJson_ReturnsJsonError() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Create an event with invalid JSON data for server-intent + JsonElement badData = gsonInstance().fromJson("{\"invalid\":\"data\"}", JsonElement.class); + FDv2Event evt = new FDv2Event(FDv2EventTypes.SERVER_INTENT, badData); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(evt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.JSON_ERROR, internalError.getErrorType()); + assertTrue(internalError.getMessage().contains("Failed to deserialize")); + assertTrue(internalError.getMessage().contains(FDv2EventTypes.SERVER_INTENT)); + } + + /** + * Tests that HandleEvent returns JsonError when put-object data is malformed. + */ + @Test + public void handleEvent_WithMalformedPutObject_ReturnsJsonError() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // First set up the state with a valid server-intent + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + + // Now send a malformed put-object + JsonElement badData = gsonInstance().fromJson("{\"missing\":\"required fields\"}", JsonElement.class); + FDv2Event evt = new FDv2Event(FDv2EventTypes.PUT_OBJECT, badData); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(evt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.JSON_ERROR, internalError.getErrorType()); + assertTrue(internalError.getMessage().contains("Failed to deserialize")); + assertTrue(internalError.getMessage().contains(FDv2EventTypes.PUT_OBJECT)); + } + + /** + * Tests that HandleEvent returns JsonError when payload-transferred data is malformed. + */ + @Test + public void handleEvent_WithMalformedPayloadTransferred_ReturnsJsonError() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // First set up the state with a valid server-intent + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + + // Now send a malformed payload-transferred + JsonElement badData = gsonInstance().fromJson("{\"incomplete\":\"data\"}", JsonElement.class); + FDv2Event evt = new FDv2Event(FDv2EventTypes.PAYLOAD_TRANSFERRED, badData); + + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(evt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.JSON_ERROR, internalError.getErrorType()); + assertTrue(internalError.getMessage().contains("Failed to deserialize")); + assertTrue(internalError.getMessage().contains(FDv2EventTypes.PAYLOAD_TRANSFERRED)); + } + + // Reset Method + + /** + * Tests that Reset clears accumulated changes and resets state to Inactive. + */ + @Test + public void reset_ClearsAccumulatedChanges() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Set up state with accumulated changes + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + handler.handleEvent(createPutObjectEvent("flag", "f2", 1)); + + // Reset the handler + handler.reset(); + + // Attempting to send payload-transferred without new server-intent should return protocol error + // because reset puts the handler back to Inactive state + FDv2Event transferredEvt = createPayloadTransferredEvent("(p:p1:1)", 1); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(transferredEvt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.PROTOCOL_ERROR, internalError.getErrorType()); + assertTrue(internalError.getMessage().contains("without an intent")); + } + + /** + * Tests that Reset allows starting a new transfer cycle. + */ + @Test + public void reset_AllowsNewTransferCycle() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // First transfer cycle + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + + // Reset + handler.reset(); + + // New transfer cycle should work + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p2", 2, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f2", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p2:2)", 2)); + + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.FULL, changesetAction.getChangeset().getType()); + // Should only have f2, not f1 (which was cleared by reset) + assertEquals(1, changesetAction.getChangeset().getChanges().size()); + assertEquals("f2", changesetAction.getChangeset().getChanges().get(0).getKey()); + } + + /** + * Tests that Reset during an ongoing Full transfer properly clears partial data. + */ + @Test + public void reset_DuringFullTransfer_ClearsPartialData() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + handler.handleEvent(createPutObjectEvent("flag", "f2", 1)); + handler.handleEvent(createPutObjectEvent("flag", "f3", 1)); + + // Reset before payload-transferred + handler.reset(); + + // Start new transfer + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_CHANGES, "p2", 2, "stale")); + handler.handleEvent(createPutObjectEvent("flag", "f4", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p2:2)", 2)); + + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.PARTIAL, changesetAction.getChangeset().getType()); + assertEquals(1, changesetAction.getChangeset().getChanges().size()); + assertEquals("f4", changesetAction.getChangeset().getChanges().get(0).getKey()); + } + + /** + * Tests that Reset during an ongoing Changes transfer properly clears partial data. + */ + @Test + public void reset_DuringChangesTransfer_ClearsPartialData() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_CHANGES, "p1", 1, "stale")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + handler.handleEvent(createDeleteObjectEvent("flag", "f2", 1)); + + // Reset before payload-transferred + handler.reset(); + + // Start new transfer + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p2", 2, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f3", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p2:2)", 2)); + + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.FULL, changesetAction.getChangeset().getType()); + assertEquals(1, changesetAction.getChangeset().getChanges().size()); + assertEquals("f3", changesetAction.getChangeset().getChanges().get(0).getKey()); + } + + /** + * Tests that Reset can be called multiple times safely. + */ + @Test + public void reset_CanBeCalledMultipleTimes() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Reset on fresh handler + handler.reset(); + + // Set up state + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + + // Reset again + handler.reset(); + + // Reset yet again + handler.reset(); + + // Should still work normally + handler.handleEvent(createServerIntentEvent(IntentCode.NONE, "p1", 1, "up-to-date")); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createServerIntentEvent(IntentCode.NONE, "p1", 1, "up-to-date")); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionChangeset); + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.NONE, changesetAction.getChangeset().getType()); + } + + /** + * Tests that Reset after a completed transfer works correctly. + * Simulates connection reset after successful data transfer. + */ + @Test + public void reset_AfterCompletedTransfer_WorksCorrectly() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + // Complete a full transfer + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + FDv2ProtocolHandler.IFDv2ProtocolAction action1 = handler.handleEvent(createPayloadTransferredEvent("(p:p1:1)", 1)); + + assertTrue(action1 instanceof FDv2ProtocolHandler.FDv2ActionChangeset); + + // Reset (simulating connection reset) + handler.reset(); + + // Start new transfer after reset + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p2", 2, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f2", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action2 = handler.handleEvent(createPayloadTransferredEvent("(p:p2:2)", 2)); + + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action2; + assertEquals(FDv2ChangeSet.FDv2ChangeSetType.FULL, changesetAction.getChangeset().getType()); + assertEquals(1, changesetAction.getChangeset().getChanges().size()); + assertEquals("f2", changesetAction.getChangeset().getChanges().get(0).getKey()); + } + + /** + * Tests that Reset after receiving an error properly clears state. + */ + @Test + public void reset_AfterError_ClearsState() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p1", 1, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + + // Receive error + FDv2ProtocolHandler.IFDv2ProtocolAction errorAction = handler.handleEvent(createErrorEvent("p1", "Something went wrong")); + assertTrue(errorAction instanceof FDv2ProtocolHandler.FDv2ActionError); + + // Reset after error + handler.reset(); + + // Verify state is Inactive by attempting payload-transferred without intent + FDv2Event transferredEvt = createPayloadTransferredEvent("(p:p1:1)", 1); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(transferredEvt); + + assertTrue(action instanceof FDv2ProtocolHandler.FDv2ActionInternalError); + FDv2ProtocolHandler.FDv2ActionInternalError internalError = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + assertEquals(FDv2ProtocolHandler.FDv2ProtocolErrorType.PROTOCOL_ERROR, internalError.getErrorType()); + } + + /** + * Tests that Reset properly handles the case where mixed put and delete operations were accumulated. + */ + @Test + public void reset_WithMixedOperations_ClearsAllChanges() { + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_CHANGES, "p1", 1, "stale")); + handler.handleEvent(createPutObjectEvent("flag", "f1", 1)); + handler.handleEvent(createDeleteObjectEvent("flag", "f2", 1)); + handler.handleEvent(createPutObjectEvent("segment", "s1", 1)); + handler.handleEvent(createDeleteObjectEvent("segment", "s2", 1)); + + // Reset + handler.reset(); + + // New transfer should not include any of the previous changes + handler.handleEvent(createServerIntentEvent(IntentCode.TRANSFER_FULL, "p2", 2, "missing")); + handler.handleEvent(createPutObjectEvent("flag", "f-new", 2)); + FDv2ProtocolHandler.IFDv2ProtocolAction action = handler.handleEvent(createPayloadTransferredEvent("(p:p2:2)", 2)); + + FDv2ProtocolHandler.FDv2ActionChangeset changesetAction = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + assertEquals(1, changesetAction.getChangeset().getChanges().size()); + assertEquals("f-new", changesetAction.getChangeset().getChanges().get(0).getKey()); + } +} + From fbea8725fb2197cd7c815be0f1de60c0f38fa016 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Mon, 12 Jan 2026 15:25:46 -0500 Subject: [PATCH 02/48] adding package info files and fixing package name issue --- .../sdk/internal/fdv2/payloads/FDv2PayloadsTest.java | 2 +- .../sdk/internal/fdv2/payloads/package-info.java | 5 +++++ .../launchdarkly/sdk/internal/fdv2/sources/package-info.java | 5 +++++ 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/package-info.java create mode 100644 lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/sources/package-info.java diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2PayloadsTest.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2PayloadsTest.java index db6f7851..ae64534c 100644 --- a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2PayloadsTest.java +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2PayloadsTest.java @@ -1,4 +1,4 @@ -package com.launchdarkly.sdk.internal.fdv2; +package com.launchdarkly.sdk.internal.fdv2.payloads; import com.google.gson.JsonElement; import com.google.gson.stream.JsonReader; diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/package-info.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/package-info.java new file mode 100644 index 00000000..94d2ff15 --- /dev/null +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/payloads/package-info.java @@ -0,0 +1,5 @@ +/** + * Test classes and methods for testing FDv2 payload functionality. + */ +package com.launchdarkly.sdk.internal.fdv2.payloads; + diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/sources/package-info.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/sources/package-info.java new file mode 100644 index 00000000..054730f4 --- /dev/null +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/fdv2/sources/package-info.java @@ -0,0 +1,5 @@ +/** + * Test classes and methods for testing FDv2 protocol handler functionality. + */ +package com.launchdarkly.sdk.internal.fdv2.sources; + From adcaa0ee598129cb2ae6bea3915ea00f39130b81 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Mon, 12 Jan 2026 15:41:47 -0500 Subject: [PATCH 03/48] more checkstyle fixes --- .../sdk/internal/fdv2/payloads/FDv2Event.java | 15 +++++++++++++++ .../sdk/internal/fdv2/payloads/package-info.java | 10 ++++++++++ .../sdk/internal/fdv2/sources/FDv2ChangeSet.java | 8 ++++++++ .../fdv2/sources/FDv2ProtocolHandler.java | 2 ++ .../sdk/internal/fdv2/sources/package-info.java | 10 ++++++++++ 5 files changed, 45 insertions(+) create mode 100644 lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/package-info.java create mode 100644 lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/package-info.java diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2Event.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2Event.java index 98fd0711..37254a02 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2Event.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/FDv2Event.java @@ -141,6 +141,9 @@ public ServerIntent asServerIntent() throws SerializationException { /** * Deserializes the data element as a PutObject. + * + * @return the deserialized PutObject + * @throws SerializationException if deserialization fails */ public PutObject asPutObject() throws SerializationException { return deserializeAs(EVENT_PUT_OBJECT, PutObject::parse); @@ -148,6 +151,9 @@ public PutObject asPutObject() throws SerializationException { /** * Deserializes the data element as a DeleteObject. + * + * @return the deserialized DeleteObject + * @throws SerializationException if deserialization fails */ public DeleteObject asDeleteObject() throws SerializationException { return deserializeAs(EVENT_DELETE_OBJECT, DeleteObject::parse); @@ -155,6 +161,9 @@ public DeleteObject asDeleteObject() throws SerializationException { /** * Deserializes the data element as a PayloadTransferred. + * + * @return the deserialized PayloadTransferred + * @throws SerializationException if deserialization fails */ public PayloadTransferred asPayloadTransferred() throws SerializationException { return deserializeAs(EVENT_PAYLOAD_TRANSFERRED, PayloadTransferred::parse); @@ -162,6 +171,9 @@ public PayloadTransferred asPayloadTransferred() throws SerializationException { /** * Deserializes the data element as an Error. + * + * @return the deserialized Error + * @throws SerializationException if deserialization fails */ public Error asError() throws SerializationException { return deserializeAs(EVENT_ERROR, Error::parse); @@ -169,6 +181,9 @@ public Error asError() throws SerializationException { /** * Deserializes the data element as a Goodbye. + * + * @return the deserialized Goodbye + * @throws SerializationException if deserialization fails */ public Goodbye asGoodbye() throws SerializationException { return deserializeAs(EVENT_GOODBYE, Goodbye::parse); diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/package-info.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/package-info.java new file mode 100644 index 00000000..faf1f9a0 --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/payloads/package-info.java @@ -0,0 +1,10 @@ +/** + * This package contains FDv2 payload types and event handling. + *

+ * All types in this package are for internal LaunchDarkly use only, and are subject to change. + * They are not part of the public supported API of the SDKs, and they should not be referenced + * by application code. They have public scope only because they need to be available to + * LaunchDarkly SDK code in other packages. + */ +package com.launchdarkly.sdk.internal.fdv2.payloads; + diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ChangeSet.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ChangeSet.java index 15fafdbf..8ffc69e1 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ChangeSet.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ChangeSet.java @@ -90,6 +90,8 @@ public int getVersion() { /** * The raw JSON string representing the object data (only present for Put operations). + * + * @return the raw JSON element representing the object data */ public JsonElement getObject() { return object; @@ -115,6 +117,8 @@ public FDv2ChangeSet(FDv2ChangeSetType type, List changes, Selector /** * The intent code indicating how the server intends to transfer data. + * + * @return the type of changeset */ public FDv2ChangeSetType getType() { return type; @@ -122,6 +126,8 @@ public FDv2ChangeSetType getType() { /** * The list of changes in this changeset. May be empty if there are no changes. + * + * @return the list of changes in this changeset */ public List getChanges() { return changes; @@ -129,6 +135,8 @@ public List getChanges() { /** * The selector (version identifier) for this changeset. + * + * @return the selector for this changeset */ public Selector getSelector() { return selector; diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandler.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandler.java index c7ee8c74..5bdd7fc8 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandler.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/FDv2ProtocolHandler.java @@ -311,6 +311,8 @@ public IFDv2ProtocolAction handleEvent(FDv2Event evt) { /** * Get a list of event types which are handled by the protocol handler. + * + * @return the list of handled event types */ public static List getHandledEventTypes() { return HANDLED_EVENT_TYPES; diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/package-info.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/package-info.java new file mode 100644 index 00000000..09d584d6 --- /dev/null +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/fdv2/sources/package-info.java @@ -0,0 +1,10 @@ +/** + * This package contains FDv2 protocol handler and related source functionality. + *

+ * All types in this package are for internal LaunchDarkly use only, and are subject to change. + * They are not part of the public supported API of the SDKs, and they should not be referenced + * by application code. They have public scope only because they need to be available to + * LaunchDarkly SDK code in other packages. + */ +package com.launchdarkly.sdk.internal.fdv2.sources; + From f2b209d46f363a2ca20ad7ccfac558424f56bf40 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 13 Jan 2026 10:53:29 -0800 Subject: [PATCH 04/48] chore: Add interfaces for synchronizer/initializer. --- .../com/launchdarkly/sdk/server/Version.java | 2 - .../server/datasources/FDv2SourceResult.java | 97 +++++++++++++++++++ .../sdk/server/datasources/Initializer.java | 57 +++++++++++ .../sdk/server/datasources/Synchronizer.java | 63 ++++++++++++ 4 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java index 85a5238f..c92affab 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java @@ -4,7 +4,5 @@ abstract class Version { private Version() {} // This constant is updated automatically by our Gradle script during a release, if the project version has changed - // x-release-please-start-version static final String SDK_VERSION = "7.10.2"; - // x-release-please-end } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java new file mode 100644 index 00000000..b5377cfc --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java @@ -0,0 +1,97 @@ +package com.launchdarkly.sdk.server.datasources; +import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; + +/** + * This type is currently experimental and not subject to semantic versioning. + *

+ * The result type for FDv2 initializers and synchronizers. An FDv2 initializer produces a single result, while + * an FDv2 synchronizer produces a stream of results. + */ +public class FDv2SourceResult { + public enum State { + /** + * The data source has encountered an interruption and will attempt to reconnect. + */ + INTERRUPTED, + /** + * The data source has been shut down and will not produce any further results. + */ + SHUTDOWN, + /** + * The data source has encountered a terminal error and will not produce any further results. + */ + TERMINAL_ERROR, + /** + * The data source has been instructed to disconnect and will not produce any further results. + */ + GOODBYE, + } + + public enum ResultType { + /** + * The source has emitted a change set. This implies that the source is valid. + */ + CHANGE_SET, + /** + * The source is emitting a status which indicates a transition from being valid to being in some kind + * of error state. The source will emit a CHANGE_SET if it becomes valid again. + */ + STATUS, + } + + /** + * Represents a change in the status of the source. + */ + public static class Status { + private final State state; + private final DataSourceStatusProvider.ErrorInfo errorInfo; + + public State getState() { + return state; + } + + public DataSourceStatusProvider.ErrorInfo getErrorInfo() { + return errorInfo; + } + + public Status(State state, DataSourceStatusProvider.ErrorInfo errorInfo) { + this.state = state; + this.errorInfo = errorInfo; + } + } + private final FDv2ChangeSet changeSet; + private final Status status; + + private final ResultType resultType; + + private FDv2SourceResult(FDv2ChangeSet changeSet, Status status, ResultType resultType) { + this.changeSet = changeSet; + this.status = status; + this.resultType = resultType; + } + + public static FDv2SourceResult interrupted(DataSourceStatusProvider.ErrorInfo errorInfo) { + return new FDv2SourceResult(null, new Status(State.INTERRUPTED, errorInfo), ResultType.STATUS); + } + + public static FDv2SourceResult shutdown(DataSourceStatusProvider.ErrorInfo errorInfo) { + return new FDv2SourceResult(null, new Status(State.SHUTDOWN, errorInfo), ResultType.STATUS); + } + + public static FDv2SourceResult changeSet(FDv2ChangeSet changeSet) { + return new FDv2SourceResult(changeSet, null, ResultType.CHANGE_SET); + } + + public ResultType getResultType() { + return resultType; + } + + public Status getStatus() { + return status; + } + + public FDv2ChangeSet getChangeSet() { + return changeSet; + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java new file mode 100644 index 00000000..332ca857 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java @@ -0,0 +1,57 @@ +package com.launchdarkly.sdk.server.datasources; + +import java.util.concurrent.CompletableFuture; + +/** + * This type is currently experimental and not subject to semantic versioning. + *

+ * Interface for an asynchronous data source initializer. + *

+ * An initializer will run and produce a single result. If the initializer is successful, then it should emit a result + * containing a change set. If the initializer fails, then it should emit a status result describing the error. + *

+ * [START] + * │ + * ▼ + * ┌─────────────┐ + * │ RUNNING │──┐ + * └─────────────┘ │ + * │ │ │ │ │ + * │ │ │ │ └──► SHUTDOWN ───► [END] + * │ │ │ │ + * │ │ │ └─────► INTERRUPTED ───► [END] + * │ │ │ + * │ │ └─────────► CHANGESET ───► [END] + * │ │ + * │ └─────────────► TERMINAL_ERROR ───► [END] + * │ + * └─────────────────► GOODBYE ───► [END] + * + * + * stateDiagram-v2 + * [*] --> RUNNING + * RUNNING --> SHUTDOWN + * RUNNING --> INTERRUPTED + * RUNNING --> CHANGESET + * RUNNING --> TERMINAL_ERROR + * RUNNING --> GOODBYE + * SHUTDOWN --> [*] + * INTERRUPTED --> [*] + * CHANGESET --> [*] + * TERMINAL_ERROR --> [*] + * GOODBYE --> [*] + * + */ +public interface Initializer { + /** + * Run the initializer to completion. + * @return The result of the initializer. + */ + CompletableFuture run(); + + /** + * Shutdown the initializer. The initializer should emit a status event with a SHUTDOWN state as soon as possible. + * If the initializer has already completed, or is in the process of completing, this method should have no effect. + */ + void shutdown(); +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java new file mode 100644 index 00000000..f972f39c --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java @@ -0,0 +1,63 @@ +package com.launchdarkly.sdk.server.datasources; + +import java.util.concurrent.CompletableFuture; + +/** + * This type is currently experimental and not subject to semantic versioning. + *

+ * Interface for an asynchronous data source synchronizer. + *

+ * A synchronizer will run and produce a stream of results. When it experiences a temporary failure, it will emit a + * status event indicating that it is INTERRUPTED, while it attempts to resolve its failure. When it receives data, + * it should emit a result containing a change set. When the data source is shut down gracefully, it should emit a + * status event indicating that it is SHUTDOWN. + *

+ * [START] + * │ + * ▼ + * ┌─────────────┐ + * ┌─►│ RUNNING │──┐ + * │ └─────────────┘ │ + * │ │ │ │ │ │ + * │ │ │ │ │ └──► SHUTDOWN ───► [END] + * │ │ │ │ │ + * │ │ │ │ └──────► TERMINAL_ERROR ───► [END] + * │ │ │ │ + * │ │ │ └──────────► GOODBYE ───► [END] + * │ │ │ + * │ │ └──────────────► CHANGE_SET ───┐ + * │ │ │ + * │ └──────────────────► INTERRUPTED ──┤ + * │ │ + * └──────────────────────────────────────┘ + *

+ * + * stateDiagram-v2 + * [*] --> RUNNING + * RUNNING --> SHUTDOWN + * SHUTDOWN --> [*] + * RUNNING --> TERMINAL_ERROR + * TERMINAL_ERROR --> [*] + * RUNNING --> GOODBYE + * GOODBYE --> [*] + * RUNNING --> CHANGE_SET + * CHANGE_SET --> RUNNING + * RUNNING --> INTERRUPTED + * INTERRUPTED --> RUNNING + * + */ +interface Synchronizer { + /** + * Get the next result from the stream. + * @return a future that will complete when the next result is available + */ + CompletableFuture next(); + + /** + * Shutdown the synchronizer. The synchronizer should emit a status event with a SHUTDOWN state as soon as possible + * and then stop producing further results. If the synchronizer involves a resource, such as a network connection, + * then those resources should be released. + * If the synchronizer has already completed, or is in the process of completing, this method should have no effect. + */ + void shutdown(); +} From de2fded62a55fb6582e48d81a17d995e3cc5785c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 13 Jan 2026 10:56:58 -0800 Subject: [PATCH 05/48] Revert version change --- .../src/main/java/com/launchdarkly/sdk/server/Version.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java index c92affab..85a5238f 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java @@ -4,5 +4,7 @@ abstract class Version { private Version() {} // This constant is updated automatically by our Gradle script during a release, if the project version has changed + // x-release-please-start-version static final String SDK_VERSION = "7.10.2"; + // x-release-please-end } From 98d3b39999f15ae5aafb552eb133f86a136753ca Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:19:45 -0800 Subject: [PATCH 06/48] feat: Add FDv2 polling support. --- .../sdk/server/StandardEndpoints.java | 1 + .../com/launchdarkly/sdk/server/Version.java | 2 - .../datasources/DefaultFDv2Requestor.java | 170 ++++++++++++++++++ .../sdk/server/datasources/FDv2Requestor.java | 29 +++ .../sdk/server/datasources/PollingBase.java | 17 ++ .../datasources/PollingInitializerImpl.java | 22 +++ .../datasources/PollingSynchronizerImpl.java | 20 +++ .../StreamingSynchronizerImpl.java | 4 + .../sdk/server/datasources/Synchronizer.java | 2 +- 9 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/DefaultFDv2Requestor.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2Requestor.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingBase.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingInitializerImpl.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingSynchronizerImpl.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/StreamingSynchronizerImpl.java diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StandardEndpoints.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StandardEndpoints.java index 867d0e86..99cc0579 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StandardEndpoints.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StandardEndpoints.java @@ -13,6 +13,7 @@ private StandardEndpoints() {} static final String STREAMING_REQUEST_PATH = "/all"; static final String POLLING_REQUEST_PATH = "/sdk/latest-all"; + static final String FDV2_POLLING_REQUEST_PATH = "/sdk/poll"; /** * Internal method to decide which URI a given component should connect to. diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java index 85a5238f..c92affab 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java @@ -4,7 +4,5 @@ abstract class Version { private Version() {} // This constant is updated automatically by our Gradle script during a release, if the project version has changed - // x-release-please-start-version static final String SDK_VERSION = "7.10.2"; - // x-release-please-end } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/DefaultFDv2Requestor.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/DefaultFDv2Requestor.java new file mode 100644 index 00000000..286b3840 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/DefaultFDv2Requestor.java @@ -0,0 +1,170 @@ +package com.launchdarkly.sdk.server.datasources; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +import com.launchdarkly.sdk.internal.http.HttpHelpers; +import com.launchdarkly.sdk.internal.http.HttpProperties; +import com.launchdarkly.sdk.json.SerializationException; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.Headers; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +import javax.annotation.Nonnull; + +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * Implementation of FDv2Requestor for polling feature flag data via FDv2 protocol. + */ +public class DefaultFDv2Requestor implements FDv2Requestor { + private static final String VERSION_QUERY_PARAM = "version"; + private static final String STATE_QUERY_PARAM = "state"; + + private final OkHttpClient httpClient; + private final URI pollingUri; + private final Headers headers; + private final LDLogger logger; + private final Map etags; + + /** + * Creates a DefaultFDv2Requestor. + * + * @param httpProperties HTTP configuration properties + * @param baseUri base URI for the FDv2 polling endpoint + * @param requestPath the request path to append to the base URI (e.g., "/sdk/poll") + * @param logger logger for diagnostic output + */ + public DefaultFDv2Requestor(HttpProperties httpProperties, URI baseUri, String requestPath, LDLogger logger) { + this.logger = logger; + this.pollingUri = HttpHelpers.concatenateUriPath(baseUri, requestPath); + this.etags = new HashMap<>(); + + OkHttpClient.Builder httpBuilder = httpProperties.toHttpClientBuilder(); + this.headers = httpProperties.toHeadersBuilder().build(); + this.httpClient = httpBuilder.build(); + } + + @Override + public CompletableFuture> Poll(Selector selector) { + CompletableFuture> future = new CompletableFuture<>(); + + try { + // Build the request URI with query parameters + URI requestUri = pollingUri; + + if (selector.getVersion() > 0) { + requestUri = HttpHelpers.addQueryParam(requestUri, VERSION_QUERY_PARAM, String.valueOf(selector.getVersion())); + } + + if (selector.getState() != null && !selector.getState().isEmpty()) { + requestUri = HttpHelpers.addQueryParam(requestUri, STATE_QUERY_PARAM, selector.getState()); + } + + logger.debug("Making FDv2 polling request to: {}", requestUri); + + // Build the HTTP request + Request.Builder requestBuilder = new Request.Builder() + .url(requestUri.toURL()) + .headers(headers) + .get(); + + // Add ETag if we have one cached for this URI + synchronized (etags) { + String etag = etags.get(requestUri); + if (etag != null) { + requestBuilder.header("If-None-Match", etag); + } + } + + Request request = requestBuilder.build(); + final URI finalRequestUri = requestUri; + + // Make asynchronous HTTP call + httpClient.newCall(request).enqueue(new Callback() { + @Override + public void onFailure(@Nonnull Call call, @Nonnull IOException e) { + if (e instanceof SocketTimeoutException) { + future.completeExceptionally( + new IOException("FDv2 polling request timed out: " + finalRequestUri, e) + ); + } else { + future.completeExceptionally(e); + } + } + + @Override + public void onResponse(@Nonnull Call call, @Nonnull Response response) { + try { + // Handle 304 Not Modified - no new data + if (response.code() == 304) { + logger.debug("FDv2 polling request returned 304: not modified"); + future.complete(Collections.emptyList()); + return; + } + + if (!response.isSuccessful()) { + future.completeExceptionally( + new IOException("FDv2 polling request failed with status code: " + response.code()) + ); + return; + } + + // Update ETag cache + String newEtag = response.header("ETag"); + synchronized (etags) { + if (newEtag != null) { + etags.put(finalRequestUri, newEtag); + } else { + etags.remove(finalRequestUri); + } + } + + // Parse the response body + if (response.body() == null) { + future.completeExceptionally(new IOException("Response body is null")); + return; + } + + String responseBody = response.body().string(); + logger.debug("Received FDv2 polling response"); + + List events = FDv2Event.parseEventsArray(responseBody); + + // Create and return the response + FDv2PollingResponse pollingResponse = new FDv2PollingResponse(events, response.headers()); + future.complete(Collections.singletonList(pollingResponse)); + + } catch (IOException | SerializationException e) { + future.completeExceptionally(e); + } finally { + response.close(); + } + } + }); + + } catch (Exception e) { + future.completeExceptionally(e); + } + + return future; + } + + /** + * Closes the HTTP client and releases resources. + */ + public void close() { + HttpProperties.shutdownHttpClient(httpClient); + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2Requestor.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2Requestor.java new file mode 100644 index 00000000..e4d23a9d --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2Requestor.java @@ -0,0 +1,29 @@ +package com.launchdarkly.sdk.server.datasources; + +import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +import okhttp3.Headers; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +interface FDv2Requestor { + public static class FDv2PollingResponse { + private final List events; + private final Headers headers; + + public FDv2PollingResponse(List events, Headers headers) { + this.events = events; + this.headers = headers; + } + + public List getEvents() { + return events; + } + + public Headers getHeaders() { + return headers; + } + } + CompletableFuture> Poll(Selector selector); +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingBase.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingBase.java new file mode 100644 index 00000000..eb0ca5e6 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingBase.java @@ -0,0 +1,17 @@ +package com.launchdarkly.sdk.server.datasources; + +import com.launchdarkly.logging.LDLogger; + +class PollingBase { + private final FDv2Requestor requestor; + private final LDLogger logger; + + public PollingBase(FDv2Requestor requestor, LDLogger logger) { + this.requestor = requestor; + this.logger = logger; + } + + private FDv2SourceResult Poll() { + return null; + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingInitializerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingInitializerImpl.java new file mode 100644 index 00000000..08bd0a84 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingInitializerImpl.java @@ -0,0 +1,22 @@ +package com.launchdarkly.sdk.server.datasources; + +import com.launchdarkly.logging.LDLogger; + +import java.util.concurrent.CompletableFuture; + +class PollingInitializerImpl extends PollingBase implements Initializer { + + public PollingInitializerImpl(FDv2Requestor requestor, LDLogger logger) { + super(requestor, logger); + } + + @Override + public CompletableFuture run() { + return null; + } + + @Override + public void shutdown() { + + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingSynchronizerImpl.java new file mode 100644 index 00000000..b4748543 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingSynchronizerImpl.java @@ -0,0 +1,20 @@ +package com.launchdarkly.sdk.server.datasources; + +import com.launchdarkly.logging.LDLogger; + +import java.util.concurrent.CompletableFuture; +class PollingSynchronizerImpl extends PollingBase implements Synchronizer { + public PollingSynchronizerImpl(FDv2Requestor requestor, LDLogger logger) { + super(requestor, logger); + } + + @Override + public CompletableFuture next() { + return null; + } + + @Override + public void shutdown() { + + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/StreamingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/StreamingSynchronizerImpl.java new file mode 100644 index 00000000..d37488d5 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/StreamingSynchronizerImpl.java @@ -0,0 +1,4 @@ +package com.launchdarkly.sdk.server.datasources; + +class StreamingSynchronizerImpl { +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java index f972f39c..94d2767d 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java @@ -46,7 +46,7 @@ * INTERRUPTED --> RUNNING * */ -interface Synchronizer { +public interface Synchronizer { /** * Get the next result from the stream. * @return a future that will complete when the next result is available From 8fb88ede5f0483c8b69943ef58a2d01a0c4f3eb9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:22:21 -0800 Subject: [PATCH 07/48] WIP: Polling initializer/synchronizer. --- .../DefaultFDv2Requestor.java | 14 +- .../{datasources => }/FDv2Requestor.java | 11 +- .../launchdarkly/sdk/server/PollingBase.java | 112 +++++ .../sdk/server/PollingInitializerImpl.java | 32 ++ .../sdk/server/PollingSynchronizerImpl.java | 62 +++ .../server/datasources/FDv2SourceResult.java | 31 +- .../datasources/IterableAsyncQueue.java | 32 ++ .../sdk/server/datasources/PollingBase.java | 17 - .../datasources/PollingInitializerImpl.java | 22 - .../datasources/PollingSynchronizerImpl.java | 20 - .../server/datasources/SelectorSource.java | 7 + .../sdk/server/datasources/Synchronizer.java | 3 + .../sdk/server/datasources/package-info.java | 6 + .../sdk/server/DefaultFDv2RequestorTest.java | 448 ++++++++++++++++++ 14 files changed, 739 insertions(+), 78 deletions(-) rename lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/{datasources => }/DefaultFDv2Requestor.java (92%) rename lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/{datasources => }/FDv2Requestor.java (72%) create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingInitializerImpl.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/IterableAsyncQueue.java delete mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingBase.java delete mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingInitializerImpl.java delete mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingSynchronizerImpl.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/SelectorSource.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/package-info.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFDv2RequestorTest.java diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/DefaultFDv2Requestor.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java similarity index 92% rename from lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/DefaultFDv2Requestor.java rename to lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java index 286b3840..c6d87e01 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/DefaultFDv2Requestor.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java @@ -1,4 +1,4 @@ -package com.launchdarkly.sdk.server.datasources; +package com.launchdarkly.sdk.server; import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event; @@ -16,10 +16,10 @@ import javax.annotation.Nonnull; +import java.io.Closeable; import java.io.IOException; import java.net.SocketTimeoutException; import java.net.URI; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -28,7 +28,7 @@ /** * Implementation of FDv2Requestor for polling feature flag data via FDv2 protocol. */ -public class DefaultFDv2Requestor implements FDv2Requestor { +public class DefaultFDv2Requestor implements FDv2Requestor, Closeable { private static final String VERSION_QUERY_PARAM = "version"; private static final String STATE_QUERY_PARAM = "state"; @@ -57,8 +57,8 @@ public DefaultFDv2Requestor(HttpProperties httpProperties, URI baseUri, String r } @Override - public CompletableFuture> Poll(Selector selector) { - CompletableFuture> future = new CompletableFuture<>(); + public CompletableFuture Poll(Selector selector) { + CompletableFuture future = new CompletableFuture<>(); try { // Build the request URI with query parameters @@ -110,7 +110,7 @@ public void onResponse(@Nonnull Call call, @Nonnull Response response) { // Handle 304 Not Modified - no new data if (response.code() == 304) { logger.debug("FDv2 polling request returned 304: not modified"); - future.complete(Collections.emptyList()); + future.complete(null); return; } @@ -144,7 +144,7 @@ public void onResponse(@Nonnull Call call, @Nonnull Response response) { // Create and return the response FDv2PollingResponse pollingResponse = new FDv2PollingResponse(events, response.headers()); - future.complete(Collections.singletonList(pollingResponse)); + future.complete(pollingResponse); } catch (IOException | SerializationException e) { future.completeExceptionally(e); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2Requestor.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2Requestor.java similarity index 72% rename from lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2Requestor.java rename to lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2Requestor.java index e4d23a9d..8e5c9df7 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2Requestor.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2Requestor.java @@ -1,4 +1,4 @@ -package com.launchdarkly.sdk.server.datasources; +package com.launchdarkly.sdk.server; import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event; import com.launchdarkly.sdk.internal.fdv2.sources.Selector; @@ -7,6 +7,11 @@ import java.util.List; import java.util.concurrent.CompletableFuture; +/** + * This type is currently experimental and not subject to semantic versioning. + *

+ * Interface for making FDv2 polling requests. + */ interface FDv2Requestor { public static class FDv2PollingResponse { private final List events; @@ -25,5 +30,7 @@ public Headers getHeaders() { return headers; } } - CompletableFuture> Poll(Selector selector); + CompletableFuture Poll(Selector selector); + + void close(); } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java new file mode 100644 index 00000000..7e63d0b2 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java @@ -0,0 +1,112 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event; +import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet; +import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ProtocolHandler; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +import com.launchdarkly.sdk.internal.http.HttpErrors; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.subsystems.SerializationException; + +import java.io.IOException; +import java.util.Date; +import java.util.concurrent.CompletableFuture; + +import static com.launchdarkly.sdk.internal.http.HttpErrors.*; + +class PollingBase { + private final FDv2Requestor requestor; + private final LDLogger logger; + + public PollingBase(FDv2Requestor requestor, LDLogger logger) { + this.requestor = requestor; + this.logger = logger; + } + + protected void internalShutdown() { + requestor.close(); + } + + protected CompletableFuture poll(Selector selector, boolean oneShot) { + return requestor.Poll(selector).handle(((pollingResponse, ex) -> { + if (ex != null) { + if (ex instanceof HttpErrors.HttpErrorException) { + HttpErrors.HttpErrorException e = (HttpErrors.HttpErrorException) ex; + DataSourceStatusProvider.ErrorInfo errorInfo = DataSourceStatusProvider.ErrorInfo.fromHttpError(e.getStatus()); + boolean recoverable = e.getStatus() > 0 && !isHttpErrorRecoverable(e.getStatus()); + logger.error("Polling request failed with HTTP error: {}", e.getStatus()); + // For a one-shot request all errors are terminal. + if (oneShot) { + return FDv2SourceResult.terminalError(errorInfo); + } else { + return recoverable ? FDv2SourceResult.interrupted(errorInfo) : FDv2SourceResult.terminalError(errorInfo); + } + } else if (ex instanceof IOException) { + IOException e = (IOException) ex; + logger.error("Polling request failed with network error: {}", e.toString()); + DataSourceStatusProvider.ErrorInfo info = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.NETWORK_ERROR, + 0, + e.toString(), + new Date().toInstant() + ); + return oneShot ? FDv2SourceResult.terminalError(info) : FDv2SourceResult.interrupted(info); + } else if (ex instanceof SerializationException) { + SerializationException e = (SerializationException) ex; + logger.error("Polling request received malformed data: {}", e.toString()); + DataSourceStatusProvider.ErrorInfo info = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.INVALID_DATA, + 0, + e.toString(), + new Date().toInstant() + ); + return oneShot ? FDv2SourceResult.terminalError(info) : FDv2SourceResult.interrupted(info); + } + Exception e = (Exception) ex; + logger.error("Polling request failed with an unknown error: {}", e.toString()); + DataSourceStatusProvider.ErrorInfo info = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.UNKNOWN, + 0, + e.toString(), + new Date().toInstant() + ); + return oneShot ? FDv2SourceResult.terminalError(info) : FDv2SourceResult.interrupted(info); + } + FDv2ProtocolHandler handler = new FDv2ProtocolHandler(); + for (FDv2Event event : pollingResponse.getEvents()) { + FDv2ProtocolHandler.IFDv2ProtocolAction res = handler.handleEvent(event); + switch (res.getAction()) { + case CHANGESET: + return FDv2SourceResult.changeSet(((FDv2ProtocolHandler.FDv2ActionChangeset) res).getChangeset()); + case ERROR: + FDv2ProtocolHandler.FDv2ActionError error = ((FDv2ProtocolHandler.FDv2ActionError) res); + return FDv2SourceResult.terminalError( + new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.UNKNOWN, + 0, + error.getReason(), + new Date().toInstant())); + case GOODBYE: + return FDv2SourceResult.goodbye(((FDv2ProtocolHandler.FDv2ActionGoodbye) res).getReason()); + case NONE: + break; + case INTERNAL_ERROR: + return FDv2SourceResult.terminalError( + new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.UNKNOWN, + 0, + "Internal error occurred during polling", + new Date().toInstant())); + } + } + return FDv2SourceResult.terminalError(new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.UNKNOWN, + 0, + "Unexpected end of polling response", + new Date().toInstant() + )); + })); + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingInitializerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingInitializerImpl.java new file mode 100644 index 00000000..3040aeee --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingInitializerImpl.java @@ -0,0 +1,32 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; +import com.launchdarkly.sdk.server.datasources.Initializer; +import com.launchdarkly.sdk.server.datasources.SelectorSource; + +import java.util.concurrent.CompletableFuture; + +class PollingInitializerImpl extends PollingBase implements Initializer { + private final CompletableFuture shutdownFuture = new CompletableFuture<>(); + private final SelectorSource selectorSource; + + public PollingInitializerImpl(FDv2Requestor requestor, LDLogger logger, SelectorSource selectorSource) { + super(requestor, logger); + this.selectorSource = selectorSource; + } + + @Override + public CompletableFuture run() { + CompletableFuture pollResult = poll(selectorSource.getSelector(), true); + return CompletableFuture.anyOf(shutdownFuture, pollResult) + .thenApply(result -> (FDv2SourceResult) result); + } + + @Override + public void shutdown() { + shutdownFuture.complete(FDv2SourceResult.shutdown()); + internalShutdown(); + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java new file mode 100644 index 00000000..b4871f74 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java @@ -0,0 +1,62 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; +import com.launchdarkly.sdk.server.datasources.IterableAsyncQueue; +import com.launchdarkly.sdk.server.datasources.SelectorSource; +import com.launchdarkly.sdk.server.datasources.Synchronizer; + +import java.time.Duration; +import java.util.concurrent.*; + +class PollingSynchronizerImpl extends PollingBase implements Synchronizer { + private final CompletableFuture shutdownFuture = new CompletableFuture<>(); + private final SelectorSource selectorSource; + + private final ScheduledFuture task; + + private final IterableAsyncQueue resultQueue = new IterableAsyncQueue<>(); + + public PollingSynchronizerImpl( + FDv2Requestor requestor, + LDLogger logger, + SelectorSource selectorSource, + ScheduledExecutorService sharedExecutor, + Duration pollInterval + ) { + super(requestor, logger); + this.selectorSource = selectorSource; + + synchronized (this) { + task = sharedExecutor.scheduleAtFixedRate( + this::doPoll, + 0L, + pollInterval.toMillis(), + TimeUnit.MILLISECONDS); + } + } + + private void doPoll() { + try { + FDv2SourceResult res = poll(selectorSource.getSelector(), true).get(); + resultQueue.put(res); + } catch (InterruptedException | ExecutionException e) { + // TODO: Determine if handling is needed. + } + } + + @Override + public CompletableFuture next() { + return CompletableFuture.anyOf(shutdownFuture, resultQueue.take()) + .thenApply(result -> (FDv2SourceResult) result); + } + + @Override + public void shutdown() { + shutdownFuture.complete(FDv2SourceResult.shutdown()); + synchronized (this) { + task.cancel(true); + } + internalShutdown(); + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java index b5377cfc..6d11f979 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java @@ -1,4 +1,5 @@ package com.launchdarkly.sdk.server.datasources; + import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; @@ -60,6 +61,7 @@ public Status(State state, DataSourceStatusProvider.ErrorInfo errorInfo) { this.errorInfo = errorInfo; } } + private final FDv2ChangeSet changeSet; private final Status status; @@ -75,23 +77,32 @@ public static FDv2SourceResult interrupted(DataSourceStatusProvider.ErrorInfo er return new FDv2SourceResult(null, new Status(State.INTERRUPTED, errorInfo), ResultType.STATUS); } - public static FDv2SourceResult shutdown(DataSourceStatusProvider.ErrorInfo errorInfo) { - return new FDv2SourceResult(null, new Status(State.SHUTDOWN, errorInfo), ResultType.STATUS); + public static FDv2SourceResult shutdown() { + return new FDv2SourceResult(null, new Status(State.SHUTDOWN, null), ResultType.STATUS); } - public static FDv2SourceResult changeSet(FDv2ChangeSet changeSet) { + public static FDv2SourceResult terminalError(DataSourceStatusProvider.ErrorInfo errorInfo) { + return new FDv2SourceResult(null, new Status(State.TERMINAL_ERROR, errorInfo), ResultType.STATUS); + } + + public static FDv2SourceResult changeSet(FDv2ChangeSet changeSet) { return new FDv2SourceResult(changeSet, null, ResultType.CHANGE_SET); - } + } - public ResultType getResultType() { + public static FDv2SourceResult goodbye(String reason) { + // TODO: Goodbye reason. + return new FDv2SourceResult(null, new Status(State.GOODBYE, null), ResultType.STATUS); + } + + public ResultType getResultType() { return resultType; - } + } - public Status getStatus() { + public Status getStatus() { return status; - } + } - public FDv2ChangeSet getChangeSet() { + public FDv2ChangeSet getChangeSet() { return changeSet; - } + } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/IterableAsyncQueue.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/IterableAsyncQueue.java new file mode 100644 index 00000000..c950ca71 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/IterableAsyncQueue.java @@ -0,0 +1,32 @@ +package com.launchdarkly.sdk.server.datasources; + +import java.util.LinkedList; +import java.util.concurrent.CompletableFuture; + +public class IterableAsyncQueue { + private final Object lock = new Object(); + private final LinkedList queue = new LinkedList<>(); + + private CompletableFuture nextFuture = null; + + public void put(T item) { + synchronized (lock) { + if(nextFuture != null) { + nextFuture.complete(item); + nextFuture = null; + } + queue.addLast(item); + } + } + public CompletableFuture take() { + synchronized (lock) { + if(!queue.isEmpty()) { + return CompletableFuture.completedFuture(queue.removeFirst()); + } + if (nextFuture == null) { + nextFuture = new CompletableFuture<>(); + } + return nextFuture; + } + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingBase.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingBase.java deleted file mode 100644 index eb0ca5e6..00000000 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingBase.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.launchdarkly.sdk.server.datasources; - -import com.launchdarkly.logging.LDLogger; - -class PollingBase { - private final FDv2Requestor requestor; - private final LDLogger logger; - - public PollingBase(FDv2Requestor requestor, LDLogger logger) { - this.requestor = requestor; - this.logger = logger; - } - - private FDv2SourceResult Poll() { - return null; - } -} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingInitializerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingInitializerImpl.java deleted file mode 100644 index 08bd0a84..00000000 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingInitializerImpl.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.launchdarkly.sdk.server.datasources; - -import com.launchdarkly.logging.LDLogger; - -import java.util.concurrent.CompletableFuture; - -class PollingInitializerImpl extends PollingBase implements Initializer { - - public PollingInitializerImpl(FDv2Requestor requestor, LDLogger logger) { - super(requestor, logger); - } - - @Override - public CompletableFuture run() { - return null; - } - - @Override - public void shutdown() { - - } -} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingSynchronizerImpl.java deleted file mode 100644 index b4748543..00000000 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/PollingSynchronizerImpl.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.launchdarkly.sdk.server.datasources; - -import com.launchdarkly.logging.LDLogger; - -import java.util.concurrent.CompletableFuture; -class PollingSynchronizerImpl extends PollingBase implements Synchronizer { - public PollingSynchronizerImpl(FDv2Requestor requestor, LDLogger logger) { - super(requestor, logger); - } - - @Override - public CompletableFuture next() { - return null; - } - - @Override - public void shutdown() { - - } -} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/SelectorSource.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/SelectorSource.java new file mode 100644 index 00000000..937ecb94 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/SelectorSource.java @@ -0,0 +1,7 @@ +package com.launchdarkly.sdk.server.datasources; + +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; + +public interface SelectorSource { + Selector getSelector(); +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java index 94d2767d..b0669e40 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java @@ -49,6 +49,9 @@ public interface Synchronizer { /** * Get the next result from the stream. + *

+ * This method is intended to be driven by a single thread, and for there to be a single outstanding call + * at any given time. * @return a future that will complete when the next result is available */ CompletableFuture next(); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/package-info.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/package-info.java new file mode 100644 index 00000000..ece5caa6 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/package-info.java @@ -0,0 +1,6 @@ +/** + * Internal data source components for FDv2 protocol support. + *

+ * This package is currently experimental and not subject to semantic versioning. + */ +package com.launchdarkly.sdk.server.datasources; diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFDv2RequestorTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFDv2RequestorTest.java new file mode 100644 index 00000000..c908e511 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFDv2RequestorTest.java @@ -0,0 +1,448 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +import com.launchdarkly.sdk.internal.http.HttpProperties; +import com.launchdarkly.sdk.server.subsystems.ClientContext; +import com.launchdarkly.testhelpers.httptest.Handler; +import com.launchdarkly.testhelpers.httptest.Handlers; +import com.launchdarkly.testhelpers.httptest.HttpServer; +import com.launchdarkly.testhelpers.httptest.RequestInfo; + +import org.junit.Test; + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@SuppressWarnings("javadoc") +public class DefaultFDv2RequestorTest extends BaseTest { + private static final String SDK_KEY = "sdk-key"; + private static final String REQUEST_PATH = "/sdk/poll"; + + // Valid FDv2 polling response with multiple events + private static final String VALID_EVENTS_JSON = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"server-intent\",\n" + + " \"data\": {\n" + + " \"payloads\": [{\n" + + " \"id\": \"payload-1\",\n" + + " \"target\": 100,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }]\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"put-object\",\n" + + " \"data\": {\n" + + " \"version\": 150,\n" + + " \"kind\": \"flag\",\n" + + " \"key\": \"test-flag\",\n" + + " \"object\": {\n" + + " \"key\": \"test-flag\",\n" + + " \"version\": 1,\n" + + " \"on\": true,\n" + + " \"fallthrough\": { \"variation\": 0 },\n" + + " \"offVariation\": 1,\n" + + " \"variations\": [true, false],\n" + + " \"salt\": \"test-salt\",\n" + + " \"trackEvents\": false,\n" + + " \"trackEventsFallthrough\": false,\n" + + " \"debugEventsUntilDate\": null,\n" + + " \"clientSide\": false,\n" + + " \"deleted\": false\n" + + " }\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"payload-transferred\",\n" + + " \"data\": {\n" + + " \"state\": \"(p:payload-1:100)\",\n" + + " \"version\": 100\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + // Empty events array + private static final String EMPTY_EVENTS_JSON = "{\"events\": []}"; + + private DefaultFDv2Requestor makeRequestor(HttpServer server) { + return makeRequestor(server, LDConfig.DEFAULT); + } + + private DefaultFDv2Requestor makeRequestor(HttpServer server, LDConfig config) { + return new DefaultFDv2Requestor(makeHttpConfig(config), server.getUri(), REQUEST_PATH, testLogger); + } + + private HttpProperties makeHttpConfig(LDConfig config) { + return ComponentsImpl.toHttpProperties(config.http.build(new ClientContext(SDK_KEY))); + } + + @Test + public void successfulRequestWithEvents() throws Exception { + Handler resp = Handlers.bodyJson(VALID_EVENTS_JSON); + + try (HttpServer server = HttpServer.start(resp)) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + CompletableFuture future = + requestor.Poll(Selector.EMPTY); + + FDv2Requestor.FDv2PollingResponse response = future.get(5, TimeUnit.SECONDS); + + assertNotNull(response); + assertNotNull(response.getEvents()); + assertEquals(3, response.getEvents().size()); + + List events = response.getEvents(); + assertEquals("server-intent", events.get(0).getEventType()); + assertEquals("put-object", events.get(1).getEventType()); + assertEquals("payload-transferred", events.get(2).getEventType()); + + RequestInfo req = server.getRecorder().requireRequest(); + assertEquals(REQUEST_PATH, req.getPath()); + } + } + } + + @Test + public void emptyEventsArray() throws Exception { + Handler resp = Handlers.bodyJson(EMPTY_EVENTS_JSON); + + try (HttpServer server = HttpServer.start(resp)) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + CompletableFuture future = + requestor.Poll(Selector.EMPTY); + + FDv2Requestor.FDv2PollingResponse response = future.get(5, TimeUnit.SECONDS); + + assertNotNull(response); + assertNotNull(response.getEvents()); + assertTrue(response.getEvents().isEmpty()); + } + } + } + + @Test + public void requestWithVersionQueryParameter() throws Exception { + Handler resp = Handlers.bodyJson(EMPTY_EVENTS_JSON); + + try (HttpServer server = HttpServer.start(resp)) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + Selector selector = Selector.make(42, null); + + CompletableFuture future = + requestor.Poll(selector); + + future.get(5, TimeUnit.SECONDS); + + RequestInfo req = server.getRecorder().requireRequest(); + assertEquals(REQUEST_PATH, req.getPath()); + assertThat(req.getQuery(), containsString("version=42")); + } + } + } + + @Test + public void requestWithStateQueryParameter() throws Exception { + Handler resp = Handlers.bodyJson(EMPTY_EVENTS_JSON); + + try (HttpServer server = HttpServer.start(resp)) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + Selector selector = Selector.make(0, "test-state"); + + CompletableFuture future = + requestor.Poll(selector); + + future.get(5, TimeUnit.SECONDS); + + RequestInfo req = server.getRecorder().requireRequest(); + assertEquals(REQUEST_PATH, req.getPath()); + assertThat(req.getQuery(), containsString("state=test-state")); + } + } + } + + @Test + public void requestWithBothQueryParameters() throws Exception { + Handler resp = Handlers.bodyJson(EMPTY_EVENTS_JSON); + + try (HttpServer server = HttpServer.start(resp)) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + Selector selector = Selector.make(100, "my-state"); + + CompletableFuture future = + requestor.Poll(selector); + + future.get(5, TimeUnit.SECONDS); + + RequestInfo req = server.getRecorder().requireRequest(); + assertEquals(REQUEST_PATH, req.getPath()); + assertThat(req.getQuery(), containsString("version=100")); + assertThat(req.getQuery(), containsString("state=my-state")); + } + } + } + + @Test + public void etagCachingWith304NotModified() throws Exception { + Handler cacheableResp = Handlers.all( + Handlers.header("ETag", "my-etag-value"), + Handlers.bodyJson(VALID_EVENTS_JSON) + ); + Handler cachedResp = Handlers.status(304); + Handler sequence = Handlers.sequential(cacheableResp, cachedResp); + + try (HttpServer server = HttpServer.start(sequence)) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + // First request should succeed and cache the ETag + CompletableFuture future1 = + requestor.Poll(Selector.EMPTY); + + FDv2Requestor.FDv2PollingResponse response1 = future1.get(5, TimeUnit.SECONDS); + assertNotNull(response1); + assertEquals(3, response1.getEvents().size()); + + RequestInfo req1 = server.getRecorder().requireRequest(); + assertEquals(REQUEST_PATH, req1.getPath()); + assertEquals(null, req1.getHeader("If-None-Match")); + + // Second request should send If-None-Match and receive 304 + CompletableFuture future2 = + requestor.Poll(Selector.EMPTY); + + FDv2Requestor.FDv2PollingResponse response2 = future2.get(5, TimeUnit.SECONDS); + assertEquals(null, response2); + + RequestInfo req2 = server.getRecorder().requireRequest(); + assertEquals(REQUEST_PATH, req2.getPath()); + assertEquals("my-etag-value", req2.getHeader("If-None-Match")); + } + } + } + + @Test + public void etagUpdatedOnNewResponse() throws Exception { + Handler resp1 = Handlers.all( + Handlers.header("ETag", "etag-1"), + Handlers.bodyJson(VALID_EVENTS_JSON) + ); + Handler resp2 = Handlers.all( + Handlers.header("ETag", "etag-2"), + Handlers.bodyJson(EMPTY_EVENTS_JSON) + ); + Handler resp3 = Handlers.status(304); + Handler sequence = Handlers.sequential(resp1, resp2, resp3); + + try (HttpServer server = HttpServer.start(sequence)) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + // First request + requestor.Poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + RequestInfo req1 = server.getRecorder().requireRequest(); + assertEquals(null, req1.getHeader("If-None-Match")); + + // Second request should use etag-1 + requestor.Poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + RequestInfo req2 = server.getRecorder().requireRequest(); + assertEquals("etag-1", req2.getHeader("If-None-Match")); + + // Third request should use etag-2 + requestor.Poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + RequestInfo req3 = server.getRecorder().requireRequest(); + assertEquals("etag-2", req3.getHeader("If-None-Match")); + } + } + } + + @Test + public void etagRemovedWhenNotInResponse() throws Exception { + Handler resp1 = Handlers.all( + Handlers.header("ETag", "etag-1"), + Handlers.bodyJson(VALID_EVENTS_JSON) + ); + Handler resp2 = Handlers.bodyJson(EMPTY_EVENTS_JSON); // No ETag + Handler resp3 = Handlers.bodyJson(EMPTY_EVENTS_JSON); // Third request + Handler sequence = Handlers.sequential(resp1, resp2, resp3); + + try (HttpServer server = HttpServer.start(sequence)) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + // First request with ETag + requestor.Poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + server.getRecorder().requireRequest(); + + // Second request should use etag-1 + requestor.Poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + RequestInfo req2 = server.getRecorder().requireRequest(); + assertEquals("etag-1", req2.getHeader("If-None-Match")); + + // Third request should not send ETag (was removed) + requestor.Poll(Selector.EMPTY).get(5, TimeUnit.SECONDS); + RequestInfo req3 = server.getRecorder().requireRequest(); + assertEquals(null, req3.getHeader("If-None-Match")); + } + } + } + + @Test + public void httpErrorCodeThrowsException() throws Exception { + Handler resp = Handlers.status(500); + + try (HttpServer server = HttpServer.start(resp)) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + CompletableFuture future = + requestor.Poll(Selector.EMPTY); + + try { + future.get(5, TimeUnit.SECONDS); + fail("Expected ExecutionException"); + } catch (ExecutionException e) { + assertThat(e.getCause(), notNullValue()); + assertThat(e.getCause().getMessage(), containsString("500")); + } + } + } + } + + @Test + public void http404ThrowsException() throws Exception { + Handler resp = Handlers.status(404); + + try (HttpServer server = HttpServer.start(resp)) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + CompletableFuture future = + requestor.Poll(Selector.EMPTY); + + try { + future.get(5, TimeUnit.SECONDS); + fail("Expected ExecutionException"); + } catch (ExecutionException e) { + assertThat(e.getCause(), notNullValue()); + assertThat(e.getCause().getMessage(), containsString("404")); + } + } + } + } + + @Test + public void invalidJsonThrowsException() throws Exception { + Handler resp = Handlers.bodyJson("{ invalid json }"); + + try (HttpServer server = HttpServer.start(resp)) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + CompletableFuture future = + requestor.Poll(Selector.EMPTY); + + try { + future.get(5, TimeUnit.SECONDS); + fail("Expected ExecutionException"); + } catch (ExecutionException e) { + assertThat(e.getCause(), notNullValue()); + } + } + } + } + + @Test + public void missingEventsPropertyThrowsException() throws Exception { + Handler resp = Handlers.bodyJson("{}"); + + try (HttpServer server = HttpServer.start(resp)) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + CompletableFuture future = + requestor.Poll(Selector.EMPTY); + + try { + future.get(5, TimeUnit.SECONDS); + fail("Expected ExecutionException"); + } catch (ExecutionException e) { + assertThat(e.getCause(), notNullValue()); + } + } + } + } + + @Test + public void baseUriCanHaveContextPath() throws Exception { + Handler resp = Handlers.bodyJson(EMPTY_EVENTS_JSON); + + try (HttpServer server = HttpServer.start(resp)) { + URI uri = server.getUri().resolve("/context/path"); + + try (DefaultFDv2Requestor requestor = new DefaultFDv2Requestor( + makeHttpConfig(LDConfig.DEFAULT), uri, REQUEST_PATH, testLogger)) { + + CompletableFuture future = + requestor.Poll(Selector.EMPTY); + + future.get(5, TimeUnit.SECONDS); + + RequestInfo req = server.getRecorder().requireRequest(); + assertEquals("/context/path" + REQUEST_PATH, req.getPath()); + } + } + } + + @Test + public void differentSelectorsUseDifferentEtags() throws Exception { + Handler resp = Handlers.all( + Handlers.header("ETag", "etag-for-request"), + Handlers.bodyJson(EMPTY_EVENTS_JSON) + ); + + try (HttpServer server = HttpServer.start(resp)) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + Selector selector1 = Selector.make(100, "state1"); + Selector selector2 = Selector.make(200, "state2"); + + // First request with selector1 + requestor.Poll(selector1).get(5, TimeUnit.SECONDS); + RequestInfo req1 = server.getRecorder().requireRequest(); + assertEquals(null, req1.getHeader("If-None-Match")); + + // Second request with selector1 should use cached ETag + requestor.Poll(selector1).get(5, TimeUnit.SECONDS); + RequestInfo req2 = server.getRecorder().requireRequest(); + assertEquals("etag-for-request", req2.getHeader("If-None-Match")); + + // Request with selector2 should not have ETag (different URI) + requestor.Poll(selector2).get(5, TimeUnit.SECONDS); + RequestInfo req3 = server.getRecorder().requireRequest(); + assertEquals(null, req3.getHeader("If-None-Match")); + } + } + } + + @Test + public void responseHeadersAreIncluded() throws Exception { + Handler resp = Handlers.all( + Handlers.header("X-Custom-Header", "custom-value"), + Handlers.bodyJson(EMPTY_EVENTS_JSON) + ); + + try (HttpServer server = HttpServer.start(resp)) { + try (DefaultFDv2Requestor requestor = makeRequestor(server)) { + CompletableFuture future = + requestor.Poll(Selector.EMPTY); + + FDv2Requestor.FDv2PollingResponse response = future.get(5, TimeUnit.SECONDS); + + assertNotNull(response); + assertNotNull(response.getHeaders()); + assertEquals("custom-value", response.getHeaders().get("X-Custom-Header")); + } + } + } +} \ No newline at end of file From da270159e9b17261298febd0dfe3132e5546f03f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:22:37 -0800 Subject: [PATCH 08/48] Use updated internal lib. --- lib/sdk/server/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/sdk/server/build.gradle b/lib/sdk/server/build.gradle index a6ce7237..3485fe8b 100644 --- a/lib/sdk/server/build.gradle +++ b/lib/sdk/server/build.gradle @@ -4,8 +4,8 @@ import java.nio.file.StandardCopyOption buildscript { repositories { - mavenCentral() mavenLocal() + mavenCentral() } dependencies { classpath "org.eclipse.virgo.util:org.eclipse.virgo.util.osgi.manifest:3.5.0.RELEASE" @@ -71,7 +71,7 @@ ext.versions = [ "guava": "32.0.1-jre", "jackson": "2.11.2", "launchdarklyJavaSdkCommon": "2.1.2", - "launchdarklyJavaSdkInternal": "1.5.1", + "launchdarklyJavaSdkInternal": "1.6.0", "launchdarklyLogging": "1.1.0", "okhttp": "4.12.0", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "4.1.0", From aba46ef808a81880a513bfb763c608b80ce993df Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:25:59 -0800 Subject: [PATCH 09/48] Update comment --- .../server/datasources/FDv2SourceResult.java | 4 +- .../server/PollingInitializerImplTest.java | 330 ++++++++++++ .../server/PollingSynchronizerImplTest.java | 492 ++++++++++++++++++ 3 files changed, 825 insertions(+), 1 deletion(-) create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java index 6d11f979..1572e7ae 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java @@ -12,7 +12,9 @@ public class FDv2SourceResult { public enum State { /** - * The data source has encountered an interruption and will attempt to reconnect. + * The data source has encountered an interruption and will attempt to reconnect. This isn't intended to be used + * with an initializer, and instead TERMINAL_ERROR should be used. When this status is used with an initializer + * it will still be a terminal state. */ INTERRUPTED, /** diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java new file mode 100644 index 00000000..8a2b2d3e --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java @@ -0,0 +1,330 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +import com.launchdarkly.sdk.internal.http.HttpErrors; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; +import com.launchdarkly.sdk.server.datasources.SelectorSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.json.SerializationException; + +import org.junit.Test; + +import java.io.IOException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SuppressWarnings("javadoc") +public class PollingInitializerImplTest extends BaseTest { + + private FDv2Requestor mockRequestor() { + return mock(FDv2Requestor.class); + } + + private SelectorSource mockSelectorSource() { + SelectorSource source = mock(SelectorSource.class); + when(source.getSelector()).thenReturn(Selector.EMPTY); + return source; + } + + private FDv2Requestor.FDv2PollingResponse makeSuccessResponse() { + String json = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"server-intent\",\n" + + " \"data\": {\n" + + " \"payloads\": [{\n" + + " \"id\": \"payload-1\",\n" + + " \"target\": 100,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }]\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"payload-transferred\",\n" + + " \"data\": {\n" + + " \"state\": \"(p:payload-1:100)\",\n" + + " \"version\": 100\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + try { + return new FDv2Requestor.FDv2PollingResponse( + com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(json), + okhttp3.Headers.of() + ); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + public void successfulInitialization() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); + assertNotNull(result.getChangeSet()); + + verify(requestor, times(1)).Poll(any(Selector.class)); + verify(requestor, times(1)).close(); + } + + @Test + public void httpRecoverableError() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.failedFuture(new HttpErrors.HttpErrorException(503))); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + assertNotNull(result.getStatus().getErrorInfo()); + assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, result.getStatus().getErrorInfo().getKind()); + + verify(requestor, times(1)).close(); + } + + @Test + public void httpNonRecoverableError() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.failedFuture(new HttpErrors.HttpErrorException(401))); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, result.getStatus().getErrorInfo().getKind()); + + verify(requestor, times(1)).close(); + } + + @Test + public void networkError() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.failedFuture(new IOException("Connection refused"))); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + assertEquals(DataSourceStatusProvider.ErrorKind.NETWORK_ERROR, result.getStatus().getErrorInfo().getKind()); + + verify(requestor, times(1)).close(); + } + + @Test + public void serializationError() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.failedFuture(new SerializationException("Invalid JSON"))); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, result.getStatus().getErrorInfo().getKind()); + + verify(requestor, times(1)).close(); + } + + @Test + public void shutdownBeforePollCompletes() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + CompletableFuture delayedResponse = new CompletableFuture<>(); + when(requestor.Poll(any(Selector.class))).thenReturn(delayedResponse); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + + // Shutdown before poll completes + Thread.sleep(100); + initializer.shutdown(); + + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.SHUTDOWN, result.getStatus().getState()); + assertNull(result.getStatus().getErrorInfo()); + + verify(requestor, times(1)).close(); + } + + @Test + public void shutdownAfterPollCompletes() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); + + // Shutdown after completion should still work + initializer.shutdown(); + + verify(requestor, times(1)).close(); + } + + @Test + public void errorEventInResponse() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + String errorJson = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"error\",\n" + + " \"data\": {\n" + + " \"error\": \"invalid-request\",\n" + + " \"reason\": \"bad request\"\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + FDv2Requestor.FDv2PollingResponse response = new FDv2Requestor.FDv2PollingResponse( + com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(errorJson), + okhttp3.Headers.of() + ); + + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + + verify(requestor, times(1)).close(); + } + + @Test + public void goodbyeEventInResponse() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + String goodbyeJson = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"goodbye\",\n" + + " \"data\": {\n" + + " \"reason\": \"service-unavailable\"\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + FDv2Requestor.FDv2PollingResponse response = new FDv2Requestor.FDv2PollingResponse( + com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(goodbyeJson), + okhttp3.Headers.of() + ); + + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.GOODBYE, result.getStatus().getState()); + + verify(requestor, times(1)).close(); + } + + @Test + public void emptyEventsArray() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + String emptyJson = "{\"events\": []}"; + + FDv2Requestor.FDv2PollingResponse response = new FDv2Requestor.FDv2PollingResponse( + com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(emptyJson), + okhttp3.Headers.of() + ); + + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + // Empty events array should result in terminal error + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + + verify(requestor, times(1)).close(); + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java new file mode 100644 index 00000000..8e526ac3 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java @@ -0,0 +1,492 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; +import com.launchdarkly.sdk.server.datasources.SelectorSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; + +import org.junit.Test; + +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SuppressWarnings("javadoc") +public class PollingSynchronizerImplTest extends BaseTest { + + private FDv2Requestor mockRequestor() { + return mock(FDv2Requestor.class); + } + + private SelectorSource mockSelectorSource() { + SelectorSource source = mock(SelectorSource.class); + when(source.getSelector()).thenReturn(Selector.EMPTY); + return source; + } + + private FDv2Requestor.FDv2PollingResponse makeSuccessResponse() { + String json = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"server-intent\",\n" + + " \"data\": {\n" + + " \"payloads\": [{\n" + + " \"id\": \"payload-1\",\n" + + " \"target\": 100,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }]\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"payload-transferred\",\n" + + " \"data\": {\n" + + " \"state\": \"(p:payload-1:100)\",\n" + + " \"version\": 100\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + try { + return new FDv2Requestor.FDv2PollingResponse( + com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(json), + okhttp3.Headers.of() + ); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + public void nextWaitsWhenQueueEmpty() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + + // Delay the response so queue is initially empty + CompletableFuture delayedResponse = new CompletableFuture<>(); + when(requestor.Poll(any(Selector.class))).thenReturn(delayedResponse); + + try { + PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(100) + ); + + CompletableFuture nextFuture = synchronizer.next(); + + // Verify future is not complete yet + Thread.sleep(50); + assertEquals(false, nextFuture.isDone()); + + // Complete the delayed response + delayedResponse.complete(makeSuccessResponse()); + + // Now the future should complete + FDv2SourceResult result = nextFuture.get(5, TimeUnit.SECONDS); + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); + + synchronizer.shutdown(); + } finally { + executor.shutdown(); + } + } + + @Test + public void nextReturnsImmediatelyWhenResultQueued() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + + FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + try { + PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(50) + ); + + // Wait for first poll to complete and queue result + Thread.sleep(150); + + // Now next() should return immediately + CompletableFuture nextFuture = synchronizer.next(); + assertTrue(nextFuture.isDone()); + + FDv2SourceResult result = nextFuture.get(1, TimeUnit.SECONDS); + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); + + synchronizer.shutdown(); + } finally { + executor.shutdown(); + } + } + + @Test + public void multipleItemsQueuedReturnedInOrder() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + + FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + try { + PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(50) + ); + + // Wait for multiple polls to complete and queue results + Thread.sleep(250); + + // Should have at least 3-4 results queued + FDv2SourceResult result1 = synchronizer.next().get(1, TimeUnit.SECONDS); + assertNotNull(result1); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result1.getResultType()); + + FDv2SourceResult result2 = synchronizer.next().get(1, TimeUnit.SECONDS); + assertNotNull(result2); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result2.getResultType()); + + FDv2SourceResult result3 = synchronizer.next().get(1, TimeUnit.SECONDS); + assertNotNull(result3); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result3.getResultType()); + + synchronizer.shutdown(); + } finally { + executor.shutdown(); + } + } + + @Test + public void shutdownBeforeNextCalled() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + + FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + try { + PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(100) + ); + + // Shutdown immediately + synchronizer.shutdown(); + + // next() should return shutdown result + FDv2SourceResult result = synchronizer.next().get(5, TimeUnit.SECONDS); + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.SHUTDOWN, result.getStatus().getState()); + } finally { + executor.shutdown(); + } + } + + @Test + public void shutdownWhileNextWaiting() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + + // Delay the response so next() will be waiting + CompletableFuture delayedResponse = new CompletableFuture<>(); + when(requestor.Poll(any(Selector.class))).thenReturn(delayedResponse); + + try { + PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(100) + ); + + CompletableFuture nextFuture = synchronizer.next(); + + // Verify next() is waiting + Thread.sleep(50); + assertEquals(false, nextFuture.isDone()); + + // Shutdown while waiting + synchronizer.shutdown(); + + // next() should complete with shutdown result + FDv2SourceResult result = nextFuture.get(5, TimeUnit.SECONDS); + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.SHUTDOWN, result.getStatus().getState()); + } finally { + executor.shutdown(); + } + } + + @Test + public void shutdownAfterMultipleItemsQueued() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + + FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + try { + PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(50) + ); + + // Wait for multiple polls to complete + Thread.sleep(250); + + // Consume one result + FDv2SourceResult result1 = synchronizer.next().get(1, TimeUnit.SECONDS); + assertNotNull(result1); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result1.getResultType()); + + // Shutdown with items still in queue + synchronizer.shutdown(); + + // Can still consume queued items + FDv2SourceResult result2 = synchronizer.next().get(1, TimeUnit.SECONDS); + assertNotNull(result2); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result2.getResultType()); + + FDv2SourceResult result3 = synchronizer.next().get(1, TimeUnit.SECONDS); + assertNotNull(result3); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result3.getResultType()); + + // Eventually should get shutdown result + FDv2SourceResult shutdownResult = synchronizer.next().get(1, TimeUnit.SECONDS); + assertNotNull(shutdownResult); + assertEquals(FDv2SourceResult.ResultType.STATUS, shutdownResult.getResultType()); + assertEquals(FDv2SourceResult.State.SHUTDOWN, shutdownResult.getStatus().getState()); + } finally { + executor.shutdown(); + } + } + + @Test + public void pollingContinuesInBackground() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + + AtomicInteger pollCount = new AtomicInteger(0); + when(requestor.Poll(any(Selector.class))).thenAnswer(invocation -> { + pollCount.incrementAndGet(); + return CompletableFuture.completedFuture(makeSuccessResponse()); + }); + + try { + PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(50) + ); + + // Wait for several poll intervals + Thread.sleep(250); + + // Should have polled multiple times + int count = pollCount.get(); + assertTrue("Expected multiple polls, got " + count, count >= 3); + + synchronizer.shutdown(); + } finally { + executor.shutdown(); + } + } + + @Test + public void errorInPollingQueuedAsInterrupted() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + + // First poll succeeds, second fails + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(makeSuccessResponse())) + .thenReturn(CompletableFuture.failedFuture(new IOException("Network error"))); + + try { + PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(100) + ); + + // First result should be success + FDv2SourceResult result1 = synchronizer.next().get(5, TimeUnit.SECONDS); + assertNotNull(result1); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result1.getResultType()); + + // Second result should be interrupted error + FDv2SourceResult result2 = synchronizer.next().get(5, TimeUnit.SECONDS); + assertNotNull(result2); + assertEquals(FDv2SourceResult.ResultType.STATUS, result2.getResultType()); + assertEquals(FDv2SourceResult.State.INTERRUPTED, result2.getStatus().getState()); + assertEquals(DataSourceStatusProvider.ErrorKind.NETWORK_ERROR, result2.getStatus().getErrorInfo().getKind()); + + synchronizer.shutdown(); + } finally { + executor.shutdown(); + } + } + + @Test + public void taskCancelledOnShutdown() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + + AtomicInteger pollCount = new AtomicInteger(0); + when(requestor.Poll(any(Selector.class))).thenAnswer(invocation -> { + pollCount.incrementAndGet(); + return CompletableFuture.completedFuture(makeSuccessResponse()); + }); + + try { + PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(50) + ); + + Thread.sleep(100); + int countBeforeShutdown = pollCount.get(); + + synchronizer.shutdown(); + + // Wait and verify no more polls occur + Thread.sleep(200); + int countAfterShutdown = pollCount.get(); + + // Count should not increase significantly after shutdown + assertTrue("Polling should stop after shutdown", + countAfterShutdown <= countBeforeShutdown + 1); // Allow for 1 in-flight poll + } finally { + executor.shutdown(); + } + } + + @Test + public void nullResponseHandledCorrectly() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + + // Return null (304 Not Modified) + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + + try { + PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(100) + ); + + // Wait for poll to complete + Thread.sleep(200); + + // The null response should result in terminal error (unexpected end of response) + FDv2SourceResult result = synchronizer.next().get(5, TimeUnit.SECONDS); + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + + synchronizer.shutdown(); + } finally { + executor.shutdown(); + } + } + + @Test + public void multipleConsumersCanCallNext() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + + FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + try { + PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(50) + ); + + // Wait for some results to queue + Thread.sleep(200); + + // Multiple consumers get results + CompletableFuture future1 = synchronizer.next(); + CompletableFuture future2 = synchronizer.next(); + CompletableFuture future3 = synchronizer.next(); + + FDv2SourceResult result1 = future1.get(5, TimeUnit.SECONDS); + FDv2SourceResult result2 = future2.get(5, TimeUnit.SECONDS); + FDv2SourceResult result3 = future3.get(5, TimeUnit.SECONDS); + + assertNotNull(result1); + assertNotNull(result2); + assertNotNull(result3); + + synchronizer.shutdown(); + } finally { + executor.shutdown(); + } + } +} From 7401331b40cbcb230554666e9fbf775eb65db53e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:30:03 -0800 Subject: [PATCH 10/48] Add termination. --- .../sdk/server/PollingSynchronizerImpl.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java index b4871f74..1c08f129 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java @@ -39,6 +39,27 @@ public PollingSynchronizerImpl( private void doPoll() { try { FDv2SourceResult res = poll(selectorSource.getSelector(), true).get(); + switch(res.getResultType()) { + case CHANGE_SET: + break; + case STATUS: + switch(res.getStatus().getState()) { + case INTERRUPTED: + break; + case SHUTDOWN: + // The base poller doesn't emit shutdown, we instead handle it at this level. + // So when shutdown is called, we return shutdown on subsequent calls to next. + break; + case TERMINAL_ERROR: + case GOODBYE: + synchronized (this) { + task.cancel(true); + } + internalShutdown(); + break; + } + break; + } resultQueue.put(res); } catch (InterruptedException | ExecutionException e) { // TODO: Determine if handling is needed. From bba0cdcf92ac825bdae87c8beee261cfc42c05bf Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:32:19 -0800 Subject: [PATCH 11/48] Remove test file that isn't ready. --- .../server/PollingInitializerImplTest.java | 330 ------------------ 1 file changed, 330 deletions(-) delete mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java deleted file mode 100644 index 8a2b2d3e..00000000 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java +++ /dev/null @@ -1,330 +0,0 @@ -package com.launchdarkly.sdk.server; - -import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet; -import com.launchdarkly.sdk.internal.fdv2.sources.Selector; -import com.launchdarkly.sdk.internal.http.HttpErrors; -import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; -import com.launchdarkly.sdk.server.datasources.SelectorSource; -import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; -import com.launchdarkly.sdk.json.SerializationException; - -import org.junit.Test; - -import java.io.IOException; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@SuppressWarnings("javadoc") -public class PollingInitializerImplTest extends BaseTest { - - private FDv2Requestor mockRequestor() { - return mock(FDv2Requestor.class); - } - - private SelectorSource mockSelectorSource() { - SelectorSource source = mock(SelectorSource.class); - when(source.getSelector()).thenReturn(Selector.EMPTY); - return source; - } - - private FDv2Requestor.FDv2PollingResponse makeSuccessResponse() { - String json = "{\n" + - " \"events\": [\n" + - " {\n" + - " \"event\": \"server-intent\",\n" + - " \"data\": {\n" + - " \"payloads\": [{\n" + - " \"id\": \"payload-1\",\n" + - " \"target\": 100,\n" + - " \"intentCode\": \"xfer-full\",\n" + - " \"reason\": \"payload-missing\"\n" + - " }]\n" + - " }\n" + - " },\n" + - " {\n" + - " \"event\": \"payload-transferred\",\n" + - " \"data\": {\n" + - " \"state\": \"(p:payload-1:100)\",\n" + - " \"version\": 100\n" + - " }\n" + - " }\n" + - " ]\n" + - "}"; - - try { - return new FDv2Requestor.FDv2PollingResponse( - com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(json), - okhttp3.Headers.of() - ); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Test - public void successfulInitialization() throws Exception { - FDv2Requestor requestor = mockRequestor(); - SelectorSource selectorSource = mockSelectorSource(); - - FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); - when(requestor.Poll(any(Selector.class))) - .thenReturn(CompletableFuture.completedFuture(response)); - - PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); - - CompletableFuture resultFuture = initializer.run(); - FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); - - assertNotNull(result); - assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); - assertNotNull(result.getChangeSet()); - - verify(requestor, times(1)).Poll(any(Selector.class)); - verify(requestor, times(1)).close(); - } - - @Test - public void httpRecoverableError() throws Exception { - FDv2Requestor requestor = mockRequestor(); - SelectorSource selectorSource = mockSelectorSource(); - - when(requestor.Poll(any(Selector.class))) - .thenReturn(CompletableFuture.failedFuture(new HttpErrors.HttpErrorException(503))); - - PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); - - CompletableFuture resultFuture = initializer.run(); - FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); - - assertNotNull(result); - assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); - assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); - assertNotNull(result.getStatus().getErrorInfo()); - assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, result.getStatus().getErrorInfo().getKind()); - - verify(requestor, times(1)).close(); - } - - @Test - public void httpNonRecoverableError() throws Exception { - FDv2Requestor requestor = mockRequestor(); - SelectorSource selectorSource = mockSelectorSource(); - - when(requestor.Poll(any(Selector.class))) - .thenReturn(CompletableFuture.failedFuture(new HttpErrors.HttpErrorException(401))); - - PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); - - CompletableFuture resultFuture = initializer.run(); - FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); - - assertNotNull(result); - assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); - assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); - assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, result.getStatus().getErrorInfo().getKind()); - - verify(requestor, times(1)).close(); - } - - @Test - public void networkError() throws Exception { - FDv2Requestor requestor = mockRequestor(); - SelectorSource selectorSource = mockSelectorSource(); - - when(requestor.Poll(any(Selector.class))) - .thenReturn(CompletableFuture.failedFuture(new IOException("Connection refused"))); - - PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); - - CompletableFuture resultFuture = initializer.run(); - FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); - - assertNotNull(result); - assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); - assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); - assertEquals(DataSourceStatusProvider.ErrorKind.NETWORK_ERROR, result.getStatus().getErrorInfo().getKind()); - - verify(requestor, times(1)).close(); - } - - @Test - public void serializationError() throws Exception { - FDv2Requestor requestor = mockRequestor(); - SelectorSource selectorSource = mockSelectorSource(); - - when(requestor.Poll(any(Selector.class))) - .thenReturn(CompletableFuture.failedFuture(new SerializationException("Invalid JSON"))); - - PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); - - CompletableFuture resultFuture = initializer.run(); - FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); - - assertNotNull(result); - assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); - assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); - assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, result.getStatus().getErrorInfo().getKind()); - - verify(requestor, times(1)).close(); - } - - @Test - public void shutdownBeforePollCompletes() throws Exception { - FDv2Requestor requestor = mockRequestor(); - SelectorSource selectorSource = mockSelectorSource(); - - CompletableFuture delayedResponse = new CompletableFuture<>(); - when(requestor.Poll(any(Selector.class))).thenReturn(delayedResponse); - - PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); - - CompletableFuture resultFuture = initializer.run(); - - // Shutdown before poll completes - Thread.sleep(100); - initializer.shutdown(); - - FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); - - assertNotNull(result); - assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); - assertEquals(FDv2SourceResult.State.SHUTDOWN, result.getStatus().getState()); - assertNull(result.getStatus().getErrorInfo()); - - verify(requestor, times(1)).close(); - } - - @Test - public void shutdownAfterPollCompletes() throws Exception { - FDv2Requestor requestor = mockRequestor(); - SelectorSource selectorSource = mockSelectorSource(); - - FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); - when(requestor.Poll(any(Selector.class))) - .thenReturn(CompletableFuture.completedFuture(response)); - - PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); - - CompletableFuture resultFuture = initializer.run(); - FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); - - assertNotNull(result); - assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); - - // Shutdown after completion should still work - initializer.shutdown(); - - verify(requestor, times(1)).close(); - } - - @Test - public void errorEventInResponse() throws Exception { - FDv2Requestor requestor = mockRequestor(); - SelectorSource selectorSource = mockSelectorSource(); - - String errorJson = "{\n" + - " \"events\": [\n" + - " {\n" + - " \"event\": \"error\",\n" + - " \"data\": {\n" + - " \"error\": \"invalid-request\",\n" + - " \"reason\": \"bad request\"\n" + - " }\n" + - " }\n" + - " ]\n" + - "}"; - - FDv2Requestor.FDv2PollingResponse response = new FDv2Requestor.FDv2PollingResponse( - com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(errorJson), - okhttp3.Headers.of() - ); - - when(requestor.Poll(any(Selector.class))) - .thenReturn(CompletableFuture.completedFuture(response)); - - PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); - - CompletableFuture resultFuture = initializer.run(); - FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); - - assertNotNull(result); - assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); - assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); - - verify(requestor, times(1)).close(); - } - - @Test - public void goodbyeEventInResponse() throws Exception { - FDv2Requestor requestor = mockRequestor(); - SelectorSource selectorSource = mockSelectorSource(); - - String goodbyeJson = "{\n" + - " \"events\": [\n" + - " {\n" + - " \"event\": \"goodbye\",\n" + - " \"data\": {\n" + - " \"reason\": \"service-unavailable\"\n" + - " }\n" + - " }\n" + - " ]\n" + - "}"; - - FDv2Requestor.FDv2PollingResponse response = new FDv2Requestor.FDv2PollingResponse( - com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(goodbyeJson), - okhttp3.Headers.of() - ); - - when(requestor.Poll(any(Selector.class))) - .thenReturn(CompletableFuture.completedFuture(response)); - - PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); - - CompletableFuture resultFuture = initializer.run(); - FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); - - assertNotNull(result); - assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); - assertEquals(FDv2SourceResult.State.GOODBYE, result.getStatus().getState()); - - verify(requestor, times(1)).close(); - } - - @Test - public void emptyEventsArray() throws Exception { - FDv2Requestor requestor = mockRequestor(); - SelectorSource selectorSource = mockSelectorSource(); - - String emptyJson = "{\"events\": []}"; - - FDv2Requestor.FDv2PollingResponse response = new FDv2Requestor.FDv2PollingResponse( - com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(emptyJson), - okhttp3.Headers.of() - ); - - when(requestor.Poll(any(Selector.class))) - .thenReturn(CompletableFuture.completedFuture(response)); - - PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); - - CompletableFuture resultFuture = initializer.run(); - FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); - - // Empty events array should result in terminal error - assertNotNull(result); - assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); - assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); - - verify(requestor, times(1)).close(); - } -} From 89bd0171146ea2d5e07cc28724ce01620e57e060 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:39:02 -0800 Subject: [PATCH 12/48] Polling tests and some fixes. --- .../sdk/server/PollingSynchronizerImpl.java | 2 +- .../server/PollingInitializerImplTest.java | 336 ++++++++++++++++++ .../server/PollingSynchronizerImplTest.java | 270 ++++++++------ 3 files changed, 489 insertions(+), 119 deletions(-) create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java index 1c08f129..abb6dbbd 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java @@ -38,7 +38,7 @@ public PollingSynchronizerImpl( private void doPoll() { try { - FDv2SourceResult res = poll(selectorSource.getSelector(), true).get(); + FDv2SourceResult res = poll(selectorSource.getSelector(), false).get(); switch(res.getResultType()) { case CHANGE_SET: break; diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java new file mode 100644 index 00000000..116132e5 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java @@ -0,0 +1,336 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +import com.launchdarkly.sdk.internal.http.HttpErrors; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; +import com.launchdarkly.sdk.server.datasources.SelectorSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.subsystems.SerializationException; + +import org.junit.Test; + +import java.io.IOException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SuppressWarnings("javadoc") +public class PollingInitializerImplTest extends BaseTest { + + private FDv2Requestor mockRequestor() { + return mock(FDv2Requestor.class); + } + + private SelectorSource mockSelectorSource() { + SelectorSource source = mock(SelectorSource.class); + when(source.getSelector()).thenReturn(Selector.EMPTY); + return source; + } + + // Helper for Java 8 compatibility - failedFuture() is Java 9+ + private CompletableFuture failedFuture(Throwable ex) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(ex); + return future; + } + + private FDv2Requestor.FDv2PollingResponse makeSuccessResponse() { + String json = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"server-intent\",\n" + + " \"data\": {\n" + + " \"payloads\": [{\n" + + " \"id\": \"payload-1\",\n" + + " \"target\": 100,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }]\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"payload-transferred\",\n" + + " \"data\": {\n" + + " \"state\": \"(p:payload-1:100)\",\n" + + " \"version\": 100\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + try { + return new FDv2Requestor.FDv2PollingResponse( + com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(json), + okhttp3.Headers.of() + ); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + public void successfulInitialization() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); + assertNotNull(result.getChangeSet()); + + verify(requestor, times(1)).Poll(any(Selector.class)); + } + + @Test + public void httpRecoverableError() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + when(requestor.Poll(any(Selector.class))) + .thenReturn(failedFuture(new HttpErrors.HttpErrorException(503))); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + assertNotNull(result.getStatus().getErrorInfo()); + assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, result.getStatus().getErrorInfo().getKind()); + + + } + + @Test + public void httpNonRecoverableError() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + when(requestor.Poll(any(Selector.class))) + .thenReturn(failedFuture(new HttpErrors.HttpErrorException(401))); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, result.getStatus().getErrorInfo().getKind()); + + + } + + @Test + public void networkError() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + when(requestor.Poll(any(Selector.class))) + .thenReturn(failedFuture(new IOException("Connection refused"))); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + assertEquals(DataSourceStatusProvider.ErrorKind.NETWORK_ERROR, result.getStatus().getErrorInfo().getKind()); + + + } + + @Test + public void serializationError() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + when(requestor.Poll(any(Selector.class))) + .thenReturn(failedFuture(new SerializationException(new Exception("Invalid JSON")))); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, result.getStatus().getErrorInfo().getKind()); + + + } + + @Test + public void shutdownBeforePollCompletes() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + CompletableFuture delayedResponse = new CompletableFuture<>(); + when(requestor.Poll(any(Selector.class))).thenReturn(delayedResponse); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + + // Shutdown before poll completes + Thread.sleep(100); + initializer.shutdown(); + + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.SHUTDOWN, result.getStatus().getState()); + assertNull(result.getStatus().getErrorInfo()); + + + } + + @Test + public void shutdownAfterPollCompletes() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); + + // Shutdown after completion should still work + initializer.shutdown(); + + + } + + @Test + public void errorEventInResponse() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + String errorJson = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"error\",\n" + + " \"data\": {\n" + + " \"error\": \"invalid-request\",\n" + + " \"reason\": \"bad request\"\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + FDv2Requestor.FDv2PollingResponse response = new FDv2Requestor.FDv2PollingResponse( + com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(errorJson), + okhttp3.Headers.of() + ); + + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + + + } + + @Test + public void goodbyeEventInResponse() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + String goodbyeJson = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"goodbye\",\n" + + " \"data\": {\n" + + " \"reason\": \"service-unavailable\"\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; + + FDv2Requestor.FDv2PollingResponse response = new FDv2Requestor.FDv2PollingResponse( + com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(goodbyeJson), + okhttp3.Headers.of() + ); + + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.GOODBYE, result.getStatus().getState()); + + + } + + @Test + public void emptyEventsArray() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + String emptyJson = "{\"events\": []}"; + + FDv2Requestor.FDv2PollingResponse response = new FDv2Requestor.FDv2PollingResponse( + com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(emptyJson), + okhttp3.Headers.of() + ); + + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + // Empty events array should result in terminal error + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + + + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java index 8e526ac3..a4e1add4 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java @@ -10,7 +10,6 @@ import java.io.IOException; import java.time.Duration; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -36,34 +35,41 @@ private SelectorSource mockSelectorSource() { return source; } + // Helper for Java 8 compatibility - CompletableFuture.failedFuture() is Java 9+ + private CompletableFuture failedFuture(Throwable ex) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(ex); + return future; + } + private FDv2Requestor.FDv2PollingResponse makeSuccessResponse() { String json = "{\n" + - " \"events\": [\n" + - " {\n" + - " \"event\": \"server-intent\",\n" + - " \"data\": {\n" + - " \"payloads\": [{\n" + - " \"id\": \"payload-1\",\n" + - " \"target\": 100,\n" + - " \"intentCode\": \"xfer-full\",\n" + - " \"reason\": \"payload-missing\"\n" + - " }]\n" + - " }\n" + - " },\n" + - " {\n" + - " \"event\": \"payload-transferred\",\n" + - " \"data\": {\n" + - " \"state\": \"(p:payload-1:100)\",\n" + - " \"version\": 100\n" + - " }\n" + - " }\n" + - " ]\n" + - "}"; + " \"events\": [\n" + + " {\n" + + " \"event\": \"server-intent\",\n" + + " \"data\": {\n" + + " \"payloads\": [{\n" + + " \"id\": \"payload-1\",\n" + + " \"target\": 100,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }]\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"payload-transferred\",\n" + + " \"data\": {\n" + + " \"state\": \"(p:payload-1:100)\",\n" + + " \"version\": 100\n" + + " }\n" + + " }\n" + + " ]\n" + + "}"; try { return new FDv2Requestor.FDv2PollingResponse( - com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(json), - okhttp3.Headers.of() + com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(json), + okhttp3.Headers.of() ); } catch (Exception e) { throw new RuntimeException(e); @@ -82,11 +88,11 @@ public void nextWaitsWhenQueueEmpty() throws Exception { try { PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( - requestor, - testLogger, - selectorSource, - executor, - Duration.ofMillis(100) + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(100) ); CompletableFuture nextFuture = synchronizer.next(); @@ -117,15 +123,15 @@ public void nextReturnsImmediatelyWhenResultQueued() throws Exception { FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); when(requestor.Poll(any(Selector.class))) - .thenReturn(CompletableFuture.completedFuture(response)); + .thenReturn(CompletableFuture.completedFuture(response)); try { PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( - requestor, - testLogger, - selectorSource, - executor, - Duration.ofMillis(50) + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(50) ); // Wait for first poll to complete and queue result @@ -153,15 +159,15 @@ public void multipleItemsQueuedReturnedInOrder() throws Exception { FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); when(requestor.Poll(any(Selector.class))) - .thenReturn(CompletableFuture.completedFuture(response)); + .thenReturn(CompletableFuture.completedFuture(response)); try { PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( - requestor, - testLogger, - selectorSource, - executor, - Duration.ofMillis(50) + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(50) ); // Wait for multiple polls to complete and queue results @@ -194,15 +200,15 @@ public void shutdownBeforeNextCalled() throws Exception { FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); when(requestor.Poll(any(Selector.class))) - .thenReturn(CompletableFuture.completedFuture(response)); + .thenReturn(CompletableFuture.completedFuture(response)); try { PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( - requestor, - testLogger, - selectorSource, - executor, - Duration.ofMillis(100) + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(100) ); // Shutdown immediately @@ -230,11 +236,11 @@ public void shutdownWhileNextWaiting() throws Exception { try { PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( - requestor, - testLogger, - selectorSource, - executor, - Duration.ofMillis(100) + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(100) ); CompletableFuture nextFuture = synchronizer.next(); @@ -264,15 +270,15 @@ public void shutdownAfterMultipleItemsQueued() throws Exception { FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); when(requestor.Poll(any(Selector.class))) - .thenReturn(CompletableFuture.completedFuture(response)); + .thenReturn(CompletableFuture.completedFuture(response)); try { PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( - requestor, - testLogger, - selectorSource, - executor, - Duration.ofMillis(50) + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(50) ); // Wait for multiple polls to complete @@ -281,25 +287,23 @@ public void shutdownAfterMultipleItemsQueued() throws Exception { // Consume one result FDv2SourceResult result1 = synchronizer.next().get(1, TimeUnit.SECONDS); assertNotNull(result1); - assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result1.getResultType()); // Shutdown with items still in queue synchronizer.shutdown(); - // Can still consume queued items - FDv2SourceResult result2 = synchronizer.next().get(1, TimeUnit.SECONDS); - assertNotNull(result2); - assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result2.getResultType()); - - FDv2SourceResult result3 = synchronizer.next().get(1, TimeUnit.SECONDS); - assertNotNull(result3); - assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result3.getResultType()); - - // Eventually should get shutdown result - FDv2SourceResult shutdownResult = synchronizer.next().get(1, TimeUnit.SECONDS); - assertNotNull(shutdownResult); - assertEquals(FDv2SourceResult.ResultType.STATUS, shutdownResult.getResultType()); - assertEquals(FDv2SourceResult.State.SHUTDOWN, shutdownResult.getStatus().getState()); + // next() can return either queued items or shutdown + // Just verify we get valid results and eventually shutdown + boolean gotShutdown = false; + for (int i = 0; i < 10; i++) { + FDv2SourceResult result = synchronizer.next().get(1, TimeUnit.SECONDS); + assertNotNull(result); + if (result.getResultType() == FDv2SourceResult.ResultType.STATUS && + result.getStatus().getState() == FDv2SourceResult.State.SHUTDOWN) { + gotShutdown = true; + break; + } + } + assertTrue("Should eventually receive shutdown result", gotShutdown); } finally { executor.shutdown(); } @@ -319,11 +323,11 @@ public void pollingContinuesInBackground() throws Exception { try { PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( - requestor, - testLogger, - selectorSource, - executor, - Duration.ofMillis(50) + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(50) ); // Wait for several poll intervals @@ -340,37 +344,56 @@ public void pollingContinuesInBackground() throws Exception { } @Test - public void errorInPollingQueuedAsInterrupted() throws Exception { + public void errorsInPollingAreSwallowed() throws Exception { FDv2Requestor requestor = mockRequestor(); SelectorSource selectorSource = mockSelectorSource(); ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); - // First poll succeeds, second fails - when(requestor.Poll(any(Selector.class))) - .thenReturn(CompletableFuture.completedFuture(makeSuccessResponse())) - .thenReturn(CompletableFuture.failedFuture(new IOException("Network error"))); + AtomicInteger callCount = new AtomicInteger(0); + AtomicInteger successCount = new AtomicInteger(0); + when(requestor.Poll(any(Selector.class))).thenAnswer(invocation -> { + int count = callCount.incrementAndGet(); + // First and third calls succeed, second fails + if (count == 2) { + return failedFuture(new IOException("Network error")); + } else { + successCount.incrementAndGet(); + return CompletableFuture.completedFuture(makeSuccessResponse()); + } + }); try { PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( - requestor, - testLogger, - selectorSource, - executor, - Duration.ofMillis(100) + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(50) ); + // Wait for multiple polls including the failed one + Thread.sleep(250); + // First result should be success - FDv2SourceResult result1 = synchronizer.next().get(5, TimeUnit.SECONDS); + FDv2SourceResult result1 = synchronizer.next().get(1, TimeUnit.SECONDS); assertNotNull(result1); assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result1.getResultType()); - // Second result should be interrupted error - FDv2SourceResult result2 = synchronizer.next().get(5, TimeUnit.SECONDS); + // Second result should be the error (INTERRUPTED status) + FDv2SourceResult result2 = synchronizer.next().get(1, TimeUnit.SECONDS); assertNotNull(result2); assertEquals(FDv2SourceResult.ResultType.STATUS, result2.getResultType()); assertEquals(FDv2SourceResult.State.INTERRUPTED, result2.getStatus().getState()); assertEquals(DataSourceStatusProvider.ErrorKind.NETWORK_ERROR, result2.getStatus().getErrorInfo().getKind()); + // Third result should be success again + FDv2SourceResult result3 = synchronizer.next().get(1, TimeUnit.SECONDS); + assertNotNull(result3); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result3.getResultType()); + + // Verify polling continued after error + assertTrue("Should have at least 2 successful polls", successCount.get() >= 2); + synchronizer.shutdown(); } finally { executor.shutdown(); @@ -391,11 +414,11 @@ public void taskCancelledOnShutdown() throws Exception { try { PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( - requestor, - testLogger, - selectorSource, - executor, - Duration.ofMillis(50) + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(50) ); Thread.sleep(100); @@ -409,39 +432,50 @@ public void taskCancelledOnShutdown() throws Exception { // Count should not increase significantly after shutdown assertTrue("Polling should stop after shutdown", - countAfterShutdown <= countBeforeShutdown + 1); // Allow for 1 in-flight poll + countAfterShutdown <= countBeforeShutdown + 1); // Allow for 1 in-flight poll } finally { executor.shutdown(); } } @Test - public void nullResponseHandledCorrectly() throws Exception { + public void nullResponseSwallowedInPolling() throws Exception { FDv2Requestor requestor = mockRequestor(); SelectorSource selectorSource = mockSelectorSource(); ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); - // Return null (304 Not Modified) - when(requestor.Poll(any(Selector.class))) - .thenReturn(CompletableFuture.completedFuture(null)); + AtomicInteger callCount = new AtomicInteger(0); + AtomicInteger successCount = new AtomicInteger(0); + when(requestor.Poll(any(Selector.class))).thenAnswer(invocation -> { + int count = callCount.incrementAndGet(); + // First call returns null (304 Not Modified), subsequent return success + if (count == 1) { + return CompletableFuture.completedFuture(null); + } else { + successCount.incrementAndGet(); + return CompletableFuture.completedFuture(makeSuccessResponse()); + } + }); try { PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( - requestor, - testLogger, - selectorSource, - executor, - Duration.ofMillis(100) + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(50) ); - // Wait for poll to complete - Thread.sleep(200); + // Wait for multiple polls + Thread.sleep(250); - // The null response should result in terminal error (unexpected end of response) - FDv2SourceResult result = synchronizer.next().get(5, TimeUnit.SECONDS); + // Should get success results - null responses cause exceptions that are swallowed + FDv2SourceResult result = synchronizer.next().get(1, TimeUnit.SECONDS); assertNotNull(result); - assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); - assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); + + // Verify polling continued after null response + assertTrue("Should have successful polls after null", successCount.get() >= 1); synchronizer.shutdown(); } finally { @@ -457,15 +491,15 @@ public void multipleConsumersCanCallNext() throws Exception { FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); when(requestor.Poll(any(Selector.class))) - .thenReturn(CompletableFuture.completedFuture(response)); + .thenReturn(CompletableFuture.completedFuture(response)); try { PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( - requestor, - testLogger, - selectorSource, - executor, - Duration.ofMillis(50) + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(50) ); // Wait for some results to queue From 228f3e65d69f2885a4f1c6f2da08ec5ae609d0db Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:42:45 -0800 Subject: [PATCH 13/48] Try pre block. --- .../com/launchdarkly/sdk/server/datasources/Initializer.java | 4 ++-- .../com/launchdarkly/sdk/server/datasources/Synchronizer.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java index 332ca857..b2c84de7 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java @@ -27,7 +27,7 @@ * │ * └─────────────────► GOODBYE ───► [END] * - * + *

  * stateDiagram-v2
  *     [*] --> RUNNING
  *     RUNNING --> SHUTDOWN
@@ -40,7 +40,7 @@
  *     CHANGESET --> [*]
  *     TERMINAL_ERROR --> [*]
  *     GOODBYE --> [*]
- * 
+ * 
*/ public interface Initializer { /** diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java index b0669e40..c386b8f6 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java @@ -31,7 +31,7 @@ * │ │ * └──────────────────────────────────────┘ *

- * + *

  * stateDiagram-v2
  *     [*] --> RUNNING
  *     RUNNING --> SHUTDOWN
@@ -44,7 +44,7 @@
  *     CHANGE_SET --> RUNNING
  *     RUNNING --> INTERRUPTED
  *     INTERRUPTED --> RUNNING
- * 
+ * 
*/ public interface Synchronizer { /** From 9469b2339fc9febe07fa3be50094cffb3fe9b23a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 14 Jan 2026 08:59:35 -0800 Subject: [PATCH 14/48] Add streaming path. --- .../main/java/com/launchdarkly/sdk/server/StandardEndpoints.java | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StandardEndpoints.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StandardEndpoints.java index 99cc0579..464e94bc 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StandardEndpoints.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StandardEndpoints.java @@ -14,6 +14,7 @@ private StandardEndpoints() {} static final String STREAMING_REQUEST_PATH = "/all"; static final String POLLING_REQUEST_PATH = "/sdk/latest-all"; static final String FDV2_POLLING_REQUEST_PATH = "/sdk/poll"; + static final String FDV2_STREAMING_REQUEST_PATH = "/sdk/stream"; /** * Internal method to decide which URI a given component should connect to. From 4b8313bbbc8dda0f93d8a4c28c50b0d46773ee50 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 14 Jan 2026 09:56:03 -0800 Subject: [PATCH 15/48] Use the DataStoreTypes.ChangeSet type for data source results. --- .../sdk/server/FDv2ChangeSetTranslator.java | 125 +++++++ .../sdk/server/FDv2DataSource.java | 4 + .../launchdarkly/sdk/server/PollingBase.java | 52 ++- .../datasources/DataSourceShutdown.java | 14 + .../server/datasources/FDv2SourceResult.java | 11 +- .../sdk/server/datasources/Initializer.java | 9 +- .../sdk/server/datasources/Synchronizer.java | 10 +- .../server/FDv2ChangeSetTranslatorTest.java | 350 ++++++++++++++++++ .../server/PollingInitializerImplTest.java | 1 - 9 files changed, 537 insertions(+), 39 deletions(-) create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2ChangeSetTranslator.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/DataSourceShutdown.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/FDv2ChangeSetTranslatorTest.java diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2ChangeSetTranslator.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2ChangeSetTranslator.java new file mode 100644 index 00000000..e3ff5395 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2ChangeSetTranslator.java @@ -0,0 +1,125 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet; +import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet.FDv2Change; +import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet.FDv2ChangeType; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSetType; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; + +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Translates FDv2 changesets into data store formats. + */ +final class FDv2ChangeSetTranslator { + private FDv2ChangeSetTranslator() { + } + + /** + * Converts an FDv2ChangeSet to a DataStoreTypes.ChangeSet. + * + * @param changeset the FDv2 changeset to convert + * @param logger logger for diagnostic messages + * @param environmentId the environment ID to include in the changeset (may be null) + * @return a DataStoreTypes.ChangeSet containing the converted data + * @throws IllegalArgumentException if the changeset type is unknown + */ + public static DataStoreTypes.ChangeSet toChangeSet( + FDv2ChangeSet changeset, + LDLogger logger, + String environmentId) { + ChangeSetType changeSetType; + switch (changeset.getType()) { + case FULL: + changeSetType = ChangeSetType.Full; + break; + case PARTIAL: + changeSetType = ChangeSetType.Partial; + break; + case NONE: + changeSetType = ChangeSetType.None; + break; + default: + throw new IllegalArgumentException( + "Unknown FDv2ChangeSetType: " + changeset.getType() + ". This is an implementation error."); + } + + // Use a LinkedHashMap to group items by DataKind in a single pass while preserving order + Map>> kindToItems = new LinkedHashMap<>(); + + for (FDv2Change change : changeset.getChanges()) { + DataKind dataKind = getDataKind(change.getKind()); + + if (dataKind == null) { + logger.warn("Unknown data kind '{}' in changeset, skipping", change.getKind()); + continue; + } + + ItemDescriptor item; + + if (change.getType() == FDv2ChangeType.PUT) { + if (change.getObject() == null) { + logger.warn( + "Put operation for {}/{} missing object data, skipping", + change.getKind(), + change.getKey()); + continue; + } + item = dataKind.deserialize(change.getObject().toString()); + } else if (change.getType() == FDv2ChangeType.DELETE) { + item = ItemDescriptor.deletedItem(change.getVersion()); + } else { + throw new IllegalArgumentException( + "Unknown FDv2ChangeType: " + change.getType() + ". This is an implementation error."); + } + + List> itemsList = + kindToItems.computeIfAbsent(dataKind, k -> new ArrayList<>()); + + itemsList.add(new AbstractMap.SimpleImmutableEntry<>(change.getKey(), item)); + } + + ImmutableList.Builder>> dataBuilder = + ImmutableList.builder(); + + for (Map.Entry>> entry : kindToItems.entrySet()) { + dataBuilder.add( + new AbstractMap.SimpleImmutableEntry<>( + entry.getKey(), + new KeyedItems<>(entry.getValue()) + )); + } + + return new DataStoreTypes.ChangeSet<>( + changeSetType, + changeset.getSelector(), + dataBuilder.build(), + environmentId); + } + + /** + * Maps an FDv2 object kind to the corresponding DataKind. + * + * @param kind the kind string from the FDv2 change + * @return the corresponding DataKind, or null if the kind is not recognized + */ + private static DataKind getDataKind(String kind) { + switch (kind) { + case "flag": + return DataModel.FEATURES; + case "segment": + return DataModel.SEGMENTS; + default: + return null; + } + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java new file mode 100644 index 00000000..1ba5d451 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java @@ -0,0 +1,4 @@ +package com.launchdarkly.sdk.server; + +public class FDv2DataSource { +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java index 7e63d0b2..f871791a 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java @@ -2,12 +2,12 @@ import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event; -import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet; import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ProtocolHandler; import com.launchdarkly.sdk.internal.fdv2.sources.Selector; import com.launchdarkly.sdk.internal.http.HttpErrors; import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes; import com.launchdarkly.sdk.server.subsystems.SerializationException; import java.io.IOException; @@ -79,26 +79,46 @@ protected CompletableFuture poll(Selector selector, boolean on FDv2ProtocolHandler.IFDv2ProtocolAction res = handler.handleEvent(event); switch (res.getAction()) { case CHANGESET: - return FDv2SourceResult.changeSet(((FDv2ProtocolHandler.FDv2ActionChangeset) res).getChangeset()); - case ERROR: + try { + + DataStoreTypes.ChangeSet converted = FDv2ChangeSetTranslator.toChangeSet( + ((FDv2ProtocolHandler.FDv2ActionChangeset) res).getChangeset(), + logger, + // TODO: Implement environment ID support. + null + ); + return FDv2SourceResult.changeSet(converted); + } catch (Exception e) { + // TODO: Do we need to be more specific about the exception type here? + DataSourceStatusProvider.ErrorInfo info = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.INVALID_DATA, + 0, + e.toString(), + new Date().toInstant() + ); + return oneShot ? FDv2SourceResult.terminalError(info) : FDv2SourceResult.interrupted(info); + } + case ERROR: { FDv2ProtocolHandler.FDv2ActionError error = ((FDv2ProtocolHandler.FDv2ActionError) res); - return FDv2SourceResult.terminalError( - new DataSourceStatusProvider.ErrorInfo( - DataSourceStatusProvider.ErrorKind.UNKNOWN, - 0, - error.getReason(), - new Date().toInstant())); + DataSourceStatusProvider.ErrorInfo info = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.UNKNOWN, + 0, + error.getReason(), + new Date().toInstant()); + return oneShot ? FDv2SourceResult.terminalError(info) : FDv2SourceResult.interrupted(info); + } case GOODBYE: return FDv2SourceResult.goodbye(((FDv2ProtocolHandler.FDv2ActionGoodbye) res).getReason()); case NONE: break; - case INTERNAL_ERROR: - return FDv2SourceResult.terminalError( - new DataSourceStatusProvider.ErrorInfo( - DataSourceStatusProvider.ErrorKind.UNKNOWN, - 0, - "Internal error occurred during polling", - new Date().toInstant())); + case INTERNAL_ERROR: { + DataSourceStatusProvider.ErrorInfo info = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.UNKNOWN, + 0, + "Internal error occurred during polling", + new Date().toInstant()); + return oneShot ? FDv2SourceResult.terminalError(info) : FDv2SourceResult.interrupted(info); + } } } return FDv2SourceResult.terminalError(new DataSourceStatusProvider.ErrorInfo( diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/DataSourceShutdown.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/DataSourceShutdown.java new file mode 100644 index 00000000..63829b12 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/DataSourceShutdown.java @@ -0,0 +1,14 @@ +package com.launchdarkly.sdk.server.datasources; + +/** + * This type is currently experimental and not subject to semantic versioning. + *

+ * Interface used to shut down a data source. + */ +public interface DataSourceShutdown { + /** + * Shutdown the data source. The data source should emit a status event with a SHUTDOWN state as soon as possible. + * If the data source has already completed, or is in the process of completing, this method should have no effect. + */ + void shutdown(); +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java index 1572e7ae..3f7ad16f 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/FDv2SourceResult.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk.server.datasources; - -import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes; /** * This type is currently experimental and not subject to semantic versioning. @@ -64,12 +63,12 @@ public Status(State state, DataSourceStatusProvider.ErrorInfo errorInfo) { } } - private final FDv2ChangeSet changeSet; + private final DataStoreTypes.ChangeSet changeSet; private final Status status; private final ResultType resultType; - private FDv2SourceResult(FDv2ChangeSet changeSet, Status status, ResultType resultType) { + private FDv2SourceResult(DataStoreTypes.ChangeSet changeSet, Status status, ResultType resultType) { this.changeSet = changeSet; this.status = status; this.resultType = resultType; @@ -87,7 +86,7 @@ public static FDv2SourceResult terminalError(DataSourceStatusProvider.ErrorInfo return new FDv2SourceResult(null, new Status(State.TERMINAL_ERROR, errorInfo), ResultType.STATUS); } - public static FDv2SourceResult changeSet(FDv2ChangeSet changeSet) { + public static FDv2SourceResult changeSet(DataStoreTypes.ChangeSet changeSet) { return new FDv2SourceResult(changeSet, null, ResultType.CHANGE_SET); } @@ -104,7 +103,7 @@ public Status getStatus() { return status; } - public FDv2ChangeSet getChangeSet() { + public DataStoreTypes.ChangeSet getChangeSet() { return changeSet; } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java index b2c84de7..5c3bdc53 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server.datasources; +import java.io.Closeable; import java.util.concurrent.CompletableFuture; /** @@ -42,16 +43,10 @@ * GOODBYE --> [*] * */ -public interface Initializer { +public interface Initializer extends DataSourceShutdown { /** * Run the initializer to completion. * @return The result of the initializer. */ CompletableFuture run(); - - /** - * Shutdown the initializer. The initializer should emit a status event with a SHUTDOWN state as soon as possible. - * If the initializer has already completed, or is in the process of completing, this method should have no effect. - */ - void shutdown(); } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java index c386b8f6..40f960ad 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java @@ -46,7 +46,7 @@ * INTERRUPTED --> RUNNING * */ -public interface Synchronizer { +public interface Synchronizer extends DataSourceShutdown { /** * Get the next result from the stream. *

@@ -55,12 +55,4 @@ public interface Synchronizer { * @return a future that will complete when the next result is available */ CompletableFuture next(); - - /** - * Shutdown the synchronizer. The synchronizer should emit a status event with a SHUTDOWN state as soon as possible - * and then stop producing further results. If the synchronizer involves a resource, such as a network connection, - * then those resources should be released. - * If the synchronizer has already completed, or is in the process of completing, this method should have no effect. - */ - void shutdown(); } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/FDv2ChangeSetTranslatorTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/FDv2ChangeSetTranslatorTest.java new file mode 100644 index 00000000..522e94f1 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/FDv2ChangeSetTranslatorTest.java @@ -0,0 +1,350 @@ +package com.launchdarkly.sdk.server; + +import com.google.common.collect.ImmutableList; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.launchdarkly.logging.LDLogLevel; +import com.launchdarkly.logging.LogCapture; +import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet; +import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet.FDv2Change; +import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet.FDv2ChangeSetType; +import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet.FDv2ChangeType; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.ChangeSetType; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class FDv2ChangeSetTranslatorTest extends BaseTest { + + private static JsonElement createFlagJsonElement(String key, int version) { + String json = String.format( + "{\n" + + " \"key\": \"%s\",\n" + + " \"version\": %d,\n" + + " \"on\": true,\n" + + " \"fallthrough\": {\"variation\": 0},\n" + + " \"variations\": [true, false]\n" + + "}", + key, version); + return JsonParser.parseString(json); + } + + private static JsonElement createSegmentJsonElement(String key, int version) { + String json = String.format( + "{\n" + + " \"key\": \"%s\",\n" + + " \"version\": %d\n" + + "}", + key, version); + return JsonParser.parseString(json); + } + + @Test + public void toChangeSet_withFullChangeset_returnsFullChangeSetType() { + List changes = ImmutableList.of( + new FDv2Change(FDv2ChangeType.PUT, "flag", "flag1", 1, createFlagJsonElement("flag1", 1)) + ); + FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); + + DataStoreTypes.ChangeSet result = + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + + assertEquals(ChangeSetType.Full, result.getType()); + } + + @Test + public void toChangeSet_withPartialChangeset_returnsPartialChangeSetType() { + List changes = ImmutableList.of( + new FDv2Change(FDv2ChangeType.PUT, "flag", "flag1", 1, createFlagJsonElement("flag1", 1)) + ); + FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.PARTIAL, changes, Selector.make(1, "state1")); + + DataStoreTypes.ChangeSet result = + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + + assertEquals(ChangeSetType.Partial, result.getType()); + } + + @Test + public void toChangeSet_withNoneChangeset_returnsNoneChangeSetType() { + List changes = ImmutableList.of(); + FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.NONE, changes, Selector.make(1, "state1")); + + DataStoreTypes.ChangeSet result = + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + + assertEquals(ChangeSetType.None, result.getType()); + } + + @Test + public void toChangeSet_includesSelector() { + List changes = ImmutableList.of(); + Selector selector = Selector.make(42, "test-state"); + FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, selector); + + DataStoreTypes.ChangeSet result = + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + + assertEquals(selector.getVersion(), result.getSelector().getVersion()); + assertEquals(selector.getState(), result.getSelector().getState()); + } + + @Test + public void toChangeSet_includesEnvironmentId() { + List changes = ImmutableList.of(); + FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); + + DataStoreTypes.ChangeSet result = + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, "test-env-id"); + + assertEquals("test-env-id", result.getEnvironmentId()); + } + + @Test + public void toChangeSet_withNullEnvironmentId_returnsNullEnvironmentId() { + List changes = ImmutableList.of(); + FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); + + DataStoreTypes.ChangeSet result = + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + + assertNull(result.getEnvironmentId()); + } + + @Test + public void toChangeSet_withPutOperation_deserializesItem() { + List changes = ImmutableList.of( + new FDv2Change(FDv2ChangeType.PUT, "flag", "flag1", 1, createFlagJsonElement("flag1", 1)) + ); + FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); + + DataStoreTypes.ChangeSet result = + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + + Map.Entry> flagData = findDataKind(result, "features"); + assertNotNull(flagData); + Map.Entry item = getFirstItem(flagData.getValue()); + assertEquals("flag1", item.getKey()); + assertNotNull(item.getValue().getItem()); + assertEquals(1, item.getValue().getVersion()); + } + + @Test + public void toChangeSet_withDeleteOperation_createsDeletedDescriptor() { + List changes = ImmutableList.of( + new FDv2Change(FDv2ChangeType.DELETE, "flag", "flag1", 5, null) + ); + FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.PARTIAL, changes, Selector.make(1, "state1")); + + DataStoreTypes.ChangeSet result = + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + + Map.Entry> flagData = findDataKind(result, "features"); + assertNotNull(flagData); + Map.Entry item = getFirstItem(flagData.getValue()); + assertEquals("flag1", item.getKey()); + assertNull(item.getValue().getItem()); + assertEquals(5, item.getValue().getVersion()); + } + + @Test + public void toChangeSet_withMultipleFlags_groupsByKind() { + List changes = ImmutableList.of( + new FDv2Change(FDv2ChangeType.PUT, "flag", "flag1", 1, createFlagJsonElement("flag1", 1)), + new FDv2Change(FDv2ChangeType.PUT, "flag", "flag2", 2, createFlagJsonElement("flag2", 2)) + ); + FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); + + DataStoreTypes.ChangeSet result = + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + + Map.Entry> flagData = findDataKind(result, "features"); + assertNotNull(flagData); + assertEquals(2, countItems(flagData.getValue())); + } + + @Test + public void toChangeSet_withFlagsAndSegments_createsMultipleDataKinds() { + List changes = ImmutableList.of( + new FDv2Change(FDv2ChangeType.PUT, "flag", "flag1", 1, createFlagJsonElement("flag1", 1)), + new FDv2Change(FDv2ChangeType.PUT, "segment", "seg1", 1, createSegmentJsonElement("seg1", 1)) + ); + FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); + + DataStoreTypes.ChangeSet result = + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + + assertEquals(2, countDataKinds(result)); + assertNotNull(findDataKind(result, "features")); + assertNotNull(findDataKind(result, "segments")); + } + + @Test + public void toChangeSet_withUnknownKind_skipsItemAndLogsWarning() { + List changes = ImmutableList.of( + new FDv2Change(FDv2ChangeType.PUT, "unknown-kind", "item1", 1, createFlagJsonElement("item1", 1)), + new FDv2Change(FDv2ChangeType.PUT, "flag", "flag1", 1, createFlagJsonElement("flag1", 1)) + ); + FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); + + DataStoreTypes.ChangeSet result = + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + + assertEquals(1, countDataKinds(result)); + assertNotNull(findDataKind(result, "features")); + assertLogMessageContains(LDLogLevel.WARN, "Unknown data kind 'unknown-kind' in changeset, skipping"); + } + + @Test + public void toChangeSet_withPutOperationMissingObject_skipsItemAndLogsWarning() { + List changes = ImmutableList.of( + new FDv2Change(FDv2ChangeType.PUT, "flag", "flag1", 1, null), + new FDv2Change(FDv2ChangeType.PUT, "flag", "flag2", 2, createFlagJsonElement("flag2", 2)) + ); + FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); + + DataStoreTypes.ChangeSet result = + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + + Map.Entry> flagData = findDataKind(result, "features"); + assertNotNull(flagData); + assertEquals(1, countItems(flagData.getValue())); + assertEquals("flag2", getFirstItem(flagData.getValue()).getKey()); + assertLogMessageContains(LDLogLevel.WARN, "Put operation for flag/flag1 missing object data, skipping"); + } + + @Test + public void toChangeSet_withEmptyChanges_returnsEmptyData() { + List changes = ImmutableList.of(); + FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); + + DataStoreTypes.ChangeSet result = + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + + assertEquals(0, countDataKinds(result)); + } + + @Test + public void toChangeSet_withMixedPutAndDelete_handlesAllOperations() { + List changes = ImmutableList.of( + new FDv2Change(FDv2ChangeType.PUT, "flag", "flag1", 1, createFlagJsonElement("flag1", 1)), + new FDv2Change(FDv2ChangeType.DELETE, "flag", "flag2", 2, null), + new FDv2Change(FDv2ChangeType.PUT, "segment", "seg1", 1, createSegmentJsonElement("seg1", 1)) + ); + FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.PARTIAL, changes, Selector.make(1, "state1")); + + DataStoreTypes.ChangeSet result = + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + + assertEquals(2, countDataKinds(result)); + + Map.Entry> flagData = findDataKind(result, "features"); + assertNotNull(flagData); + assertEquals(2, countItems(flagData.getValue())); + + Map.Entry flag1 = findItem(flagData.getValue(), "flag1"); + assertNotNull(flag1.getValue().getItem()); + assertEquals(1, flag1.getValue().getVersion()); + + Map.Entry flag2 = findItem(flagData.getValue(), "flag2"); + assertNull(flag2.getValue().getItem()); + assertEquals(2, flag2.getValue().getVersion()); + + Map.Entry> segmentData = findDataKind(result, "segments"); + assertNotNull(segmentData); + assertEquals(1, countItems(segmentData.getValue())); + } + + @Test + public void toChangeSet_preservesOrderOfChangesWithinKind() { + List changes = ImmutableList.of( + new FDv2Change(FDv2ChangeType.PUT, "flag", "flag3", 3, createFlagJsonElement("flag3", 3)), + new FDv2Change(FDv2ChangeType.PUT, "flag", "flag1", 1, createFlagJsonElement("flag1", 1)), + new FDv2Change(FDv2ChangeType.PUT, "flag", "flag2", 2, createFlagJsonElement("flag2", 2)) + ); + FDv2ChangeSet fdv2ChangeSet = new FDv2ChangeSet(FDv2ChangeSetType.FULL, changes, Selector.make(1, "state1")); + + DataStoreTypes.ChangeSet result = + FDv2ChangeSetTranslator.toChangeSet(fdv2ChangeSet, testLogger, null); + + Map.Entry> flagData = findDataKind(result, "features"); + assertNotNull(flagData); + List> items = toList(flagData.getValue().getItems()); + assertEquals("flag3", items.get(0).getKey()); + assertEquals("flag1", items.get(1).getKey()); + assertEquals("flag2", items.get(2).getKey()); + } + + // Helper methods + + private Map.Entry> findDataKind( + DataStoreTypes.ChangeSet changeSet, String kindName) { + for (Map.Entry> entry : changeSet.getData()) { + if (entry.getKey().getName().equals(kindName)) { + return entry; + } + } + return null; + } + + private Map.Entry getFirstItem( + KeyedItems keyedItems) { + return keyedItems.getItems().iterator().next(); + } + + private Map.Entry findItem( + KeyedItems keyedItems, String key) { + for (Map.Entry entry : keyedItems.getItems()) { + if (entry.getKey().equals(key)) { + return entry; + } + } + return null; + } + + private int countItems(KeyedItems keyedItems) { + int count = 0; + for (@SuppressWarnings("unused") Map.Entry entry : keyedItems.getItems()) { + count++; + } + return count; + } + + private int countDataKinds(DataStoreTypes.ChangeSet changeSet) { + int count = 0; + for (@SuppressWarnings("unused") Map.Entry> entry : changeSet.getData()) { + count++; + } + return count; + } + + private List> toList( + Iterable> items) { + List> list = new ArrayList<>(); + for (Map.Entry item : items) { + list.add(item); + } + return list; + } + + private void assertLogMessageContains(LDLogLevel level, String expectedMessageSubstring) { + for (LogCapture.Message message : logCapture.getMessages()) { + if (message.getLevel() == level && message.getText().contains(expectedMessageSubstring)) { + return; + } + } + throw new AssertionError("Expected log message at level " + level + " containing: " + expectedMessageSubstring); + } +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java index 116132e5..dd8c38a7 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java @@ -1,6 +1,5 @@ package com.launchdarkly.sdk.server; -import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ChangeSet; import com.launchdarkly.sdk.internal.fdv2.sources.Selector; import com.launchdarkly.sdk.internal.http.HttpErrors; import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; From 31eb13e7081115a4983962bc6ff9c18d095ad195 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:04:00 -0800 Subject: [PATCH 16/48] Make iterable async queue package private. --- .../sdk/server/FDv2DataSource.java | 225 +++++++++++++++++- .../{datasources => }/IterableAsyncQueue.java | 4 +- .../sdk/server/PollingSynchronizerImpl.java | 1 - .../StreamingSynchronizerImpl.java | 4 - 4 files changed, 226 insertions(+), 8 deletions(-) rename lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/{datasources => }/IterableAsyncQueue.java (90%) delete mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/StreamingSynchronizerImpl.java diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java index 1ba5d451..3a489d9d 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java @@ -1,4 +1,227 @@ package com.launchdarkly.sdk.server; -public class FDv2DataSource { +import com.launchdarkly.sdk.server.datasources.DataSourceShutdown; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; +import com.launchdarkly.sdk.server.datasources.Initializer; +import com.launchdarkly.sdk.server.datasources.Synchronizer; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.subsystems.DataSource; +import com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSink; + +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; + +class FDv2DataSource implements DataSource { + private final List initializers; + private final List synchronizers; + + private final DataSourceUpdateSink dataSourceUpdates; + + private final CompletableFuture startFuture = new CompletableFuture<>(); + private final AtomicBoolean started = new AtomicBoolean(false); + + private final Object activeSourceLock = new Object(); + private DataSourceShutdown activeSource; + + private static class SynchronizerFactoryWithState { + public enum State { + /** + * This synchronizer is available to use. + */ + Available, + + /** + * This synchronizer is no longer available to use. + */ + Blocked, + + /** + * This synchronizer is recovering from a previous failure and will be available to use + * after a delay. + */ + Recovering + } + + private final SynchronizerFactory factory; + + private State state = State.Available; + + + public SynchronizerFactoryWithState(SynchronizerFactory factory) { + this.factory = factory; + } + + public State getState() { + return state; + } + + public void block() { + state = State.Blocked; + } + + public void setRecovering(Duration delay) { + state = State.Recovering; + // TODO: Determine how/when to recover. + } + + public Synchronizer build() { + return factory.build(); + } + } + + public interface InitializerFactory { + Initializer build(); + } + + public interface SynchronizerFactory { + Synchronizer build(); + } + + + public FDv2DataSource( + List initializers, + List synchronizers, + DataSourceUpdateSink dataSourceUpdates + ) { + this.initializers = initializers; + this.synchronizers = synchronizers + .stream() + .map(SynchronizerFactoryWithState::new) + .collect(Collectors.toList()); + this.dataSourceUpdates = dataSourceUpdates; + } + + private void run() { + Thread runThread = new Thread(() -> { + if (!initializers.isEmpty()) { + runInitializers(); + } + runSynchronizers(); + // TODO: Handle. We have ran out of sources or we are shutting down. + }); + runThread.setDaemon(true); + // TODO: Thread priority. + //thread.setPriority(threadPriority); + runThread.start(); + } + + private SynchronizerFactoryWithState getFirstAvailableSynchronizer() { + synchronized (synchronizers) { + for (SynchronizerFactoryWithState synchronizer : synchronizers) { + if (synchronizer.getState() == SynchronizerFactoryWithState.State.Available) { + return synchronizer; + } + } + + return null; + } + } + + private void runSynchronizers() { + SynchronizerFactoryWithState availableSynchronizer = getFirstAvailableSynchronizer(); + // TODO: Add recovery handling. If there are no available synchronizers, but there are + // recovering ones, then we likely will want to wait for them to be available (or bypass recovery). + while (availableSynchronizer != null) { + Synchronizer synchronizer = availableSynchronizer.build(); + try { + + boolean running = true; + while (running) { + FDv2SourceResult result = synchronizer.next().get(); + switch (result.getResultType()) { + case CHANGE_SET: + // TODO: Apply to the store. + // This could have been completed by any data source. But if it has not been completed before + // now, then we complete it. + startFuture.complete(true); + break; + case STATUS: + FDv2SourceResult.Status status = result.getStatus(); + switch (status.getState()) { + case INTERRUPTED: + // TODO: Track how long we are interrupted. + break; + case SHUTDOWN: + // We should be overall shutting down. + // TODO: We may need logging or to do a little more. + return; + case TERMINAL_ERROR: + case GOODBYE: + running = false; + break; + } + break; + } + } + } catch (ExecutionException | InterruptedException | CancellationException e) { + // TODO: Log. + // Move to next synchronizer. + } + availableSynchronizer = getFirstAvailableSynchronizer(); + } + } + + private void runInitializers() { + for (InitializerFactory factory : initializers) { + try { + Initializer initializer = factory.build(); + synchronized (activeSourceLock) { + activeSource = initializer; + } + FDv2SourceResult res = initializer.run().get(); + switch (res.getResultType()) { + case CHANGE_SET: + // TODO: Apply to the store. + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); + startFuture.complete(true); + return; + case STATUS: + // TODO: Implement. + break; + } + } catch (ExecutionException | InterruptedException | CancellationException e) { + // TODO: Log. + } + } + } + + @Override + public Future start() { + if (!started.getAndSet(true)) { + run(); + } + return startFuture.thenApply(x -> null); + } + + @Override + public boolean isInitialized() { + try { + return startFuture.isDone() && startFuture.get(); + } catch (Exception e) { + return false; + } + } + + @Override + public void close() throws IOException { + // If this is already set, then this has no impact. + startFuture.complete(false); + synchronized (synchronizers) { + for (SynchronizerFactoryWithState synchronizer : synchronizers) { + synchronizer.block(); + } + } + synchronized (activeSourceLock) { + if (activeSource != null) { + activeSource.shutdown(); + } + } + } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/IterableAsyncQueue.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/IterableAsyncQueue.java similarity index 90% rename from lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/IterableAsyncQueue.java rename to lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/IterableAsyncQueue.java index c950ca71..22123545 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/IterableAsyncQueue.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/IterableAsyncQueue.java @@ -1,9 +1,9 @@ -package com.launchdarkly.sdk.server.datasources; +package com.launchdarkly.sdk.server; import java.util.LinkedList; import java.util.concurrent.CompletableFuture; -public class IterableAsyncQueue { +class IterableAsyncQueue { private final Object lock = new Object(); private final LinkedList queue = new LinkedList<>(); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java index abb6dbbd..e1b0eae0 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java @@ -2,7 +2,6 @@ import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; -import com.launchdarkly.sdk.server.datasources.IterableAsyncQueue; import com.launchdarkly.sdk.server.datasources.SelectorSource; import com.launchdarkly.sdk.server.datasources.Synchronizer; diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/StreamingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/StreamingSynchronizerImpl.java deleted file mode 100644 index d37488d5..00000000 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/StreamingSynchronizerImpl.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.launchdarkly.sdk.server.datasources; - -class StreamingSynchronizerImpl { -} From 4a2fe3bd7ced9d83dfa571d42783a156a94f7ec4 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:14:43 -0800 Subject: [PATCH 17/48] Revert Version.java --- .../src/main/java/com/launchdarkly/sdk/server/Version.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java index c92affab..85a5238f 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java @@ -4,5 +4,7 @@ abstract class Version { private Version() {} // This constant is updated automatically by our Gradle script during a release, if the project version has changed + // x-release-please-start-version static final String SDK_VERSION = "7.10.2"; + // x-release-please-end } From 3428591b7236e0cf69234c6fa17353682e8cd205 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:16:40 -0800 Subject: [PATCH 18/48] Add comments to SelectorSource. --- .../sdk/server/datasources/SelectorSource.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/SelectorSource.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/SelectorSource.java index 937ecb94..163c384e 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/SelectorSource.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/SelectorSource.java @@ -2,6 +2,15 @@ import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +/** + * This type is currently experimental and not subject to semantic versioning. + *

+ * Source of selectors for FDv2 implementations. + */ public interface SelectorSource { + /** + * Get the current selector. + * @return The current selector. + */ Selector getSelector(); } From ff60216c76cf00607d1aa9e74f1e496b5d91e4d0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:17:37 -0800 Subject: [PATCH 19/48] Revert build.gradle. --- lib/sdk/server/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/sdk/server/build.gradle b/lib/sdk/server/build.gradle index 7258346c..a6ce7237 100644 --- a/lib/sdk/server/build.gradle +++ b/lib/sdk/server/build.gradle @@ -4,8 +4,8 @@ import java.nio.file.StandardCopyOption buildscript { repositories { - mavenLocal() mavenCentral() + mavenLocal() } dependencies { classpath "org.eclipse.virgo.util:org.eclipse.virgo.util.osgi.manifest:3.5.0.RELEASE" @@ -71,7 +71,7 @@ ext.versions = [ "guava": "32.0.1-jre", "jackson": "2.11.2", "launchdarklyJavaSdkCommon": "2.1.2", - "launchdarklyJavaSdkInternal": "1.6.1", + "launchdarklyJavaSdkInternal": "1.5.1", "launchdarklyLogging": "1.1.0", "okhttp": "4.12.0", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "4.1.0", From e985f80d6ba96298708cd6dceb7d2246f5414899 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:18:47 -0800 Subject: [PATCH 20/48] Update launchdarklyJavaSdkInternal version to 1.6.1 --- lib/sdk/server/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/sdk/server/build.gradle b/lib/sdk/server/build.gradle index a6ce7237..a27e9c71 100644 --- a/lib/sdk/server/build.gradle +++ b/lib/sdk/server/build.gradle @@ -71,7 +71,7 @@ ext.versions = [ "guava": "32.0.1-jre", "jackson": "2.11.2", "launchdarklyJavaSdkCommon": "2.1.2", - "launchdarklyJavaSdkInternal": "1.5.1", + "launchdarklyJavaSdkInternal": "1.6.1", "launchdarklyLogging": "1.1.0", "okhttp": "4.12.0", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "4.1.0", From a9564842c637ce4f13f2f4a0a140565ca2e99993 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:25:22 -0800 Subject: [PATCH 21/48] Move mermaid out of doc comment. --- .../sdk/server/datasources/Initializer.java | 28 +++++++++--------- .../sdk/server/datasources/Synchronizer.java | 29 +++++++++---------- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java index 5c3bdc53..bf44a309 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java @@ -3,6 +3,20 @@ import java.io.Closeable; import java.util.concurrent.CompletableFuture; +// Mermaid source for state diagram. +// stateDiagram-v2 +// [*] --> RUNNING +// RUNNING --> SHUTDOWN +// RUNNING --> INTERRUPTED +// RUNNING --> CHANGESET +// RUNNING --> TERMINAL_ERROR +// RUNNING --> GOODBYE +// SHUTDOWN --> [*] +// INTERRUPTED --> [*] +// CHANGESET --> [*] +// TERMINAL_ERROR --> [*] +// GOODBYE --> [*] + /** * This type is currently experimental and not subject to semantic versioning. *

@@ -28,20 +42,6 @@ * │ * └─────────────────► GOODBYE ───► [END] * - *

- * stateDiagram-v2
- *     [*] --> RUNNING
- *     RUNNING --> SHUTDOWN
- *     RUNNING --> INTERRUPTED
- *     RUNNING --> CHANGESET
- *     RUNNING --> TERMINAL_ERROR
- *     RUNNING --> GOODBYE
- *     SHUTDOWN --> [*]
- *     INTERRUPTED --> [*]
- *     CHANGESET --> [*]
- *     TERMINAL_ERROR --> [*]
- *     GOODBYE --> [*]
- * 
*/ public interface Initializer extends DataSourceShutdown { /** diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java index 40f960ad..ee86f238 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java @@ -2,6 +2,20 @@ import java.util.concurrent.CompletableFuture; +// Mermaid source for state diagram. +// stateDiagram-v2 +// [*] --> RUNNING +// RUNNING --> SHUTDOWN +// SHUTDOWN --> [*] +// RUNNING --> TERMINAL_ERROR +// TERMINAL_ERROR --> [*] +// RUNNING --> GOODBYE +// GOODBYE --> [*] +// RUNNING --> CHANGE_SET +// CHANGE_SET --> RUNNING +// RUNNING --> INTERRUPTED +// INTERRUPTED --> RUNNING + /** * This type is currently experimental and not subject to semantic versioning. *

@@ -30,21 +44,6 @@ * │ └──────────────────► INTERRUPTED ──┤ * │ │ * └──────────────────────────────────────┘ - *

- *

- * stateDiagram-v2
- *     [*] --> RUNNING
- *     RUNNING --> SHUTDOWN
- *     SHUTDOWN --> [*]
- *     RUNNING --> TERMINAL_ERROR
- *     TERMINAL_ERROR --> [*]
- *     RUNNING --> GOODBYE
- *     GOODBYE --> [*]
- *     RUNNING --> CHANGE_SET
- *     CHANGE_SET --> RUNNING
- *     RUNNING --> INTERRUPTED
- *     INTERRUPTED --> RUNNING
- * 
*/ public interface Synchronizer extends DataSourceShutdown { /** From 376bb1f22d1acd77650054bd3616260d106a99c4 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:24:42 -0800 Subject: [PATCH 22/48] chore: Add streaming synchronizer. --- .../sdk/server/StreamingSynchronizerImpl.java | 379 ++++++++++++++ .../sdk/server/datasources/Initializer.java | 1 - .../server/StreamingSynchronizerImplTest.java | 473 ++++++++++++++++++ 3 files changed, 852 insertions(+), 1 deletion(-) create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java create mode 100644 lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java new file mode 100644 index 00000000..aa5f764b --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java @@ -0,0 +1,379 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.eventsource.ConnectStrategy; +import com.launchdarkly.eventsource.ErrorStrategy; +import com.launchdarkly.eventsource.EventSource; +import com.launchdarkly.eventsource.FaultEvent; +import com.launchdarkly.eventsource.HttpConnectStrategy; +import com.launchdarkly.eventsource.MessageEvent; +import com.launchdarkly.eventsource.StreamClosedByCallerException; +import com.launchdarkly.eventsource.StreamEvent; +import com.launchdarkly.eventsource.StreamException; +import com.launchdarkly.eventsource.StreamHttpErrorException; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.logging.LogValues; +import com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event; +import com.launchdarkly.sdk.internal.fdv2.sources.FDv2ProtocolHandler; +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +import com.launchdarkly.sdk.internal.http.HttpHelpers; +import com.launchdarkly.sdk.internal.http.HttpProperties; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; +import com.launchdarkly.sdk.server.datasources.SelectorSource; +import com.launchdarkly.sdk.server.datasources.Synchronizer; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes; +import com.launchdarkly.sdk.server.subsystems.SerializationException; +import com.google.gson.stream.JsonReader; +import okhttp3.Headers; + +import java.io.Reader; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.launchdarkly.sdk.internal.http.HttpErrors.checkIfErrorIsRecoverableAndLog; + +/** + * Implementation of FDv2 streaming synchronizer. + * Maintains a long-running streaming connection and queues results as they arrive. + */ +class StreamingSynchronizerImpl implements Synchronizer { + private static final Duration DEAD_CONNECTION_INTERVAL = Duration.ofSeconds(300); + + private final HttpProperties httpProperties; + private final SelectorSource selectorSource; + final URI streamUri; + private final LDLogger logger; + private final IterableAsyncQueue resultQueue = new IterableAsyncQueue<>(); + private final CompletableFuture shutdownFuture = new CompletableFuture<>(); + private final AtomicBoolean shutdownRequested = new AtomicBoolean(false); + private final FDv2ProtocolHandler protocolHandler = new FDv2ProtocolHandler(); + private volatile EventSource eventSource; + private volatile Thread streamThread; + + public StreamingSynchronizerImpl( + HttpProperties httpProperties, + URI baseUri, + String requestPath, + LDLogger logger, + SelectorSource selectorSource + ) { + this.httpProperties = httpProperties; + this.selectorSource = selectorSource; + this.logger = logger; + this.streamUri = HttpHelpers.concatenateUriPath(baseUri, requestPath); + + startStream(); + } + + private void startStream() { + Headers headers = httpProperties.toHeadersBuilder() + .add("Accept", "text/event-stream") + .build(); + + HttpConnectStrategy connectStrategy = ConnectStrategy.http(streamUri) + .headers(headers) + .clientBuilderActions(clientBuilder -> { + httpProperties.applyToHttpClientBuilder(clientBuilder); + // Add interceptor to inject selector query parameters on each request + clientBuilder.addInterceptor(chain -> { + okhttp3.Request originalRequest = chain.request(); + Selector selector = selectorSource.getSelector(); + + if (selector.isEmpty()) { + return chain.proceed(originalRequest); + } + + // Build new URL with selector query parameters + URI currentUri = originalRequest.url().uri(); + URI updatedUri = HttpHelpers.addQueryParam(currentUri, "version", String.valueOf(selector.getVersion())); + if (selector.getState() != null && !selector.getState().isEmpty()) { + updatedUri = HttpHelpers.addQueryParam(updatedUri, "state", selector.getState()); + } + + okhttp3.Request newRequest = originalRequest.newBuilder() + .url(updatedUri.toString()) + .build(); + return chain.proceed(newRequest); + }); + }) + .readTimeout(DEAD_CONNECTION_INTERVAL.toMillis(), TimeUnit.MILLISECONDS); + + EventSource.Builder builder = new EventSource.Builder(connectStrategy) + .errorStrategy(ErrorStrategy.alwaysContinue()) + .logger(logger) + .readBufferSize(5000) + .streamEventData(true) + .expectFields("event"); + + eventSource = builder.build(); + + streamThread = new Thread(() -> { + try { + for (StreamEvent event : eventSource.anyEvents()) { + if (shutdownRequested.get()) { + break; + } + + if (!handleEvent(event)) { + break; + } + } + } catch (Exception e) { + if (shutdownRequested.get()) { + return; + } + logger.error("Stream thread ended with exception: {}", LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); + + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.UNKNOWN, + 0, + e.toString(), + Instant.now() + ); + resultQueue.put(FDv2SourceResult.terminalError(errorInfo)); + } finally { + try { + if (eventSource != null) { + eventSource.close(); + } + } catch (Exception e) { + logger.debug("Error closing event source: {}", LogValues.exceptionSummary(e)); + } + } + }); + streamThread.setName("LaunchDarkly-FDv2-streaming-synchronizer"); + streamThread.setDaemon(true); + streamThread.start(); + } + + @Override + public CompletableFuture next() { + return CompletableFuture.anyOf(shutdownFuture, resultQueue.take()) + .thenApply(result -> (FDv2SourceResult) result); + } + + @Override + public void shutdown() { + if (shutdownRequested.getAndSet(true)) { + return; // already shutdown + } + + shutdownFuture.complete(FDv2SourceResult.shutdown()); + + if (eventSource != null) { + try { + eventSource.close(); + } catch (Exception e) { + logger.debug("Error closing event source during shutdown: {}", LogValues.exceptionSummary(e)); + } + } + } + + private boolean handleEvent(StreamEvent event) { + if (event instanceof MessageEvent) { + handleMessage((MessageEvent) event); + return true; + } else if (event instanceof FaultEvent) { + return handleError(((FaultEvent) event).getCause()); + } + return true; + } + + private void handleMessage(MessageEvent event) { + String eventName = null; + try { + eventName = event.getEventName(); + FDv2Event fdv2Event = parseFDv2Event(eventName, event.getDataReader()); + + FDv2ProtocolHandler.IFDv2ProtocolAction action; + try { + action = protocolHandler.handleEvent(fdv2Event); + } catch (Exception e) { + // Protocol handler threw exception processing the event - treat as invalid data + logger.error("FDv2 protocol handler error: {}", LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.INVALID_DATA, + 0, + e.toString(), + Instant.now() + ); + resultQueue.put(FDv2SourceResult.interrupted(errorInfo)); + if (eventSource != null) { + eventSource.interrupt(); // restart the stream + } + return; + } + + FDv2SourceResult result = null; + boolean shouldTerminate = false; + + switch (action.getAction()) { + case CHANGESET: + FDv2ProtocolHandler.FDv2ActionChangeset changeset = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + try { + // TODO: Environment ID. + DataStoreTypes.ChangeSet converted = + FDv2ChangeSetTranslator.toChangeSet(changeset.getChangeset(), logger, null); + result = FDv2SourceResult.changeSet(converted); + } catch (Exception e) { + logger.error("Failed to convert FDv2 changeset: {}", LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); + DataSourceStatusProvider.ErrorInfo conversionError = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.INVALID_DATA, + 0, + e.toString(), + Instant.now() + ); + result = FDv2SourceResult.interrupted(conversionError); + if (eventSource != null) { + eventSource.interrupt(); // restart the stream + } + } + break; + + case ERROR: + FDv2ProtocolHandler.FDv2ActionError error = (FDv2ProtocolHandler.FDv2ActionError) action; + // Check if this is an explicit error event from the server (terminal) + // or invalid data in another event type (recoverable) + if ("error".equals(eventName)) { + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.UNKNOWN, + 0, + error.getReason(), + Instant.now() + ); + result = FDv2SourceResult.terminalError(errorInfo); + shouldTerminate = true; + } else { + // Invalid data in other event types - recoverable + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.INVALID_DATA, + 0, + error.getReason(), + Instant.now() + ); + result = FDv2SourceResult.interrupted(errorInfo); + if (eventSource != null) { + eventSource.interrupt(); // restart the stream + } + } + break; + + case GOODBYE: + FDv2ProtocolHandler.FDv2ActionGoodbye goodbye = (FDv2ProtocolHandler.FDv2ActionGoodbye) action; + result = FDv2SourceResult.goodbye(goodbye.getReason()); + shouldTerminate = true; + break; + + case INTERNAL_ERROR: + DataSourceStatusProvider.ErrorInfo internalError = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.INVALID_DATA, + 0, + "Internal error during FDv2 event processing", + Instant.now() + ); + result = FDv2SourceResult.interrupted(internalError); + if (eventSource != null) { + eventSource.interrupt(); // restart the stream + } + break; + + case NONE: + // Continue processing events, don't queue anything + break; + } + + if (result != null) { + resultQueue.put(result); + } + + if (shouldTerminate) { + if (eventSource != null) { + eventSource.close(); + } + return; + } + } catch (SerializationException e) { + logger.error("Failed to parse FDv2 event: {}", LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.INVALID_DATA, + 0, + e.toString(), + Instant.now() + ); + // Queue as INTERRUPTED, not TERMINAL_ERROR, so we can continue processing other events + resultQueue.put(FDv2SourceResult.interrupted(errorInfo)); + if (eventSource != null) { + eventSource.interrupt(); // restart the stream + } + } catch (Exception e) { + logger.error("Unexpected error handling stream message: {}", LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.UNKNOWN, + 0, + e.toString(), + Instant.now() + ); + resultQueue.put(FDv2SourceResult.interrupted(errorInfo)); + if (eventSource != null) { + eventSource.interrupt(); // restart the stream + } + } + } + + private boolean handleError(StreamException e) { + if (e instanceof StreamClosedByCallerException) { + // We closed it ourselves (shutdown was called) + return false; + } + + if (e instanceof StreamHttpErrorException) { + int status = ((StreamHttpErrorException) e).getCode(); + DataSourceStatusProvider.ErrorInfo errorInfo = DataSourceStatusProvider.ErrorInfo.fromHttpError(status); + + boolean recoverable = checkIfErrorIsRecoverableAndLog(logger, + "HTTP error " + status, + "in FDv2 streaming connection", + status, + "will retry"); + + if (!recoverable) { + resultQueue.put(FDv2SourceResult.terminalError(errorInfo)); + return false; + } else { + // Queue as INTERRUPTED to indicate temporary failure + resultQueue.put(FDv2SourceResult.interrupted(errorInfo)); + return true; // allow reconnect + } + } + + // Network or other error - queue as INTERRUPTED and allow reconnect + logger.warn("Stream error: {}", LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.NETWORK_ERROR, + 0, + e.toString(), + Instant.now() + ); + resultQueue.put(FDv2SourceResult.interrupted(errorInfo)); + return true; // allow reconnect + } + + private FDv2Event parseFDv2Event(String eventName, Reader eventDataReader) throws SerializationException { + try { + JsonReader reader = new JsonReader(eventDataReader); + return new FDv2Event(eventName, com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance().fromJson(reader, com.google.gson.JsonElement.class)); + } catch (Exception e) { + throw new SerializationException(e); + } + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java index bf44a309..be3f0edd 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java @@ -1,6 +1,5 @@ package com.launchdarkly.sdk.server.datasources; -import java.io.Closeable; import java.util.concurrent.CompletableFuture; // Mermaid source for state diagram. diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java new file mode 100644 index 00000000..7fcbd560 --- /dev/null +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java @@ -0,0 +1,473 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +import com.launchdarkly.sdk.internal.http.HttpProperties; +import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; +import com.launchdarkly.sdk.server.datasources.SelectorSource; +import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; +import com.launchdarkly.testhelpers.httptest.Handlers; +import com.launchdarkly.testhelpers.httptest.HttpServer; +import com.launchdarkly.testhelpers.httptest.RequestInfo; +import org.junit.Test; + +import java.net.URI; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static com.launchdarkly.sdk.server.ComponentsImpl.toHttpProperties; +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SuppressWarnings("javadoc") +public class StreamingSynchronizerImplTest extends BaseTest { + + private SelectorSource mockSelectorSource() { + SelectorSource source = mock(SelectorSource.class); + when(source.getSelector()).thenReturn(Selector.EMPTY); + return source; + } + + private static String makeEvent(String type, String data) { + return "event: " + type + "\ndata: " + data; + } + + @Test + public void receivesMultipleChangesets() throws Exception { + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred1 = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + String putObject1 = makeEvent("put-object", "{\"kind\":\"flag\",\"key\":\"flag1\",\"version\":1,\"object\":{}}"); + String payloadTransferred2 = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:101)\",\"version\":101}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred1), + Handlers.SSE.event(putObject1), + Handlers.SSE.event(payloadTransferred2), + Handlers.SSE.leaveOpen()))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + SelectorSource selectorSource = mockSelectorSource(); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource + ); + + // First changeset + CompletableFuture result1Future = synchronizer.next(); + FDv2SourceResult result1 = result1Future.get(5, TimeUnit.SECONDS); + + assertNotNull(result1); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result1.getResultType()); + assertNotNull(result1.getChangeSet()); + + // Second changeset + CompletableFuture result2Future = synchronizer.next(); + FDv2SourceResult result2 = result2Future.get(5, TimeUnit.SECONDS); + + assertNotNull(result2); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result2.getResultType()); + assertNotNull(result2.getChangeSet()); + + synchronizer.shutdown(); + } + } + + @Test + public void httpNonRecoverableError() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.status(401))) { + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + SelectorSource selectorSource = mockSelectorSource(); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource + ); + + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, result.getStatus().getErrorInfo().getKind()); + + synchronizer.shutdown(); + } + } + + @Test + public void httpRecoverableError() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.status(503))) { + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + SelectorSource selectorSource = mockSelectorSource(); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource + ); + + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.INTERRUPTED, result.getStatus().getState()); + assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, result.getStatus().getErrorInfo().getKind()); + + synchronizer.shutdown(); + } + } + + @Test + public void networkError() throws Exception { + // Use an invalid port to simulate network error + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + SelectorSource selectorSource = mockSelectorSource(); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + URI.create("http://localhost:1"), // invalid port + "/stream", + testLogger, + selectorSource + ); + + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.INTERRUPTED, result.getStatus().getState()); + assertEquals(DataSourceStatusProvider.ErrorKind.NETWORK_ERROR, result.getStatus().getErrorInfo().getKind()); + + synchronizer.shutdown(); + } + + @Test + public void invalidEventData() throws Exception { + String badEvent = makeEvent("server-intent", "invalid json"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(badEvent), + Handlers.SSE.leaveOpen()))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + SelectorSource selectorSource = mockSelectorSource(); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource + ); + + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.INTERRUPTED, result.getStatus().getState()); + assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, result.getStatus().getErrorInfo().getKind()); + + synchronizer.shutdown(); + } + } + + @Test + public void shutdownBeforeEventReceived() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.hang()))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + SelectorSource selectorSource = mockSelectorSource(); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource + ); + + CompletableFuture nextFuture = synchronizer.next(); + + // Wait a bit then shutdown + Thread.sleep(100); + synchronizer.shutdown(); + + FDv2SourceResult result = nextFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.SHUTDOWN, result.getStatus().getState()); + assertNull(result.getStatus().getErrorInfo()); + } + } + + @Test + public void shutdownAfterEventReceived() throws Exception { + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen()))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + SelectorSource selectorSource = mockSelectorSource(); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource + ); + + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); + + // Shutdown after receiving event should still work + synchronizer.shutdown(); + } + } + + @Test + public void errorEventInResponse() throws Exception { + String errorEvent = makeEvent("error", "{\"error\":\"invalid-request\",\"reason\":\"bad request\"}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(errorEvent), + Handlers.SSE.leaveOpen()))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + SelectorSource selectorSource = mockSelectorSource(); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource + ); + + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + + synchronizer.shutdown(); + } + } + + @Test + public void goodbyeEventInResponse() throws Exception { + String goodbyeEvent = makeEvent("goodbye", "{\"reason\":\"service-unavailable\"}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(goodbyeEvent), + Handlers.SSE.leaveOpen()))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + SelectorSource selectorSource = mockSelectorSource(); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource + ); + + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.GOODBYE, result.getStatus().getState()); + + synchronizer.shutdown(); + } + } + + @Test + public void heartbeatEvent() throws Exception { + String heartbeatEvent = makeEvent("heartbeat", "{}"); + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(heartbeatEvent), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen()))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + SelectorSource selectorSource = mockSelectorSource(); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource + ); + + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + // Heartbeat should be ignored, and we should get the changeset + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); + assertNotNull(result.getChangeSet()); + + synchronizer.shutdown(); + } + } + + @Test + public void selectorWithVersionAndState() throws Exception { + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen()))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + + SelectorSource selectorSource = mock(SelectorSource.class); + when(selectorSource.getSelector()).thenReturn(Selector.make(50, "(p:old:50)")); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource + ); + + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); + + // Verify selector was fetched when connecting + verify(selectorSource, atLeastOnce()).getSelector(); + + // Verify the request had the correct query parameters + assertEquals(1, server.getRecorder().count()); + RequestInfo request = server.getRecorder().requireRequest(); + assertThat(request.getQuery(), containsString("version=50")); + assertThat(request.getQuery(), containsString("state=")); + + synchronizer.shutdown(); + } + } + + @Test + public void selectorRefetchedOnReconnection() throws Exception { + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + // Test reconnection with a 503 error followed by successful connection + // Add multiple successful handlers in case EventSource reconnects multiple times + try (HttpServer server = HttpServer.start(Handlers.sequential( + Handlers.status(503), + Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen()), + Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen())))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + + SelectorSource selectorSource = mock(SelectorSource.class); + when(selectorSource.getSelector()) + .thenReturn(Selector.make(50, "(p:old:50)")) + .thenReturn(Selector.make(100, "(p:new:100)")); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource + ); + + // First result should be an error from the 503 + CompletableFuture result1Future = synchronizer.next(); + FDv2SourceResult result1 = result1Future.get(5, TimeUnit.SECONDS); + assertNotNull(result1); + assertEquals(FDv2SourceResult.ResultType.STATUS, result1.getResultType()); + assertEquals(FDv2SourceResult.State.INTERRUPTED, result1.getStatus().getState()); + + // Keep getting results until we get a CHANGE_SET (reconnection successful) + // There may be multiple STATUS results if reconnection takes multiple attempts + FDv2SourceResult changesetResult = null; + for (int i = 0; i < 5; i++) { // Try up to 5 times + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(15, TimeUnit.SECONDS); + assertNotNull(result); + if (result.getResultType() == FDv2SourceResult.ResultType.CHANGE_SET) { + changesetResult = result; + break; + } + // If it's another STATUS, that's fine, just keep waiting for the changeset + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + } + + assertNotNull("Should eventually get a CHANGE_SET after reconnection", changesetResult); + + // Verify selector was fetched at least twice (initial failed connect + successful reconnect) + verify(selectorSource, atLeast(2)).getSelector(); + + // Verify we made at least 2 requests + assertTrue("Should have made at least 2 requests", server.getRecorder().count() >= 2); + + synchronizer.shutdown(); + } + } +} From 194c30c3dca6de2f9811791f8341f4b1faa387bd Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:10:36 -0800 Subject: [PATCH 23/48] PR feedback. --- .../sdk/server/DefaultFDv2Requestor.java | 10 +++-- .../sdk/server/FDv2DataSource.java | 15 +++++++- .../sdk/server/FDv2Requestor.java | 11 ++++-- .../sdk/server/DefaultFDv2RequestorTest.java | 37 +++++++++---------- .../server/PollingInitializerImplTest.java | 17 ++++----- .../server/PollingSynchronizerImplTest.java | 18 ++++----- 6 files changed, 62 insertions(+), 46 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java index c6d87e01..51fcf938 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java @@ -57,14 +57,14 @@ public DefaultFDv2Requestor(HttpProperties httpProperties, URI baseUri, String r } @Override - public CompletableFuture Poll(Selector selector) { - CompletableFuture future = new CompletableFuture<>(); + public CompletableFuture Poll(Selector selector) { + CompletableFuture future = new CompletableFuture<>(); try { // Build the request URI with query parameters URI requestUri = pollingUri; - if (selector.getVersion() > 0) { + if (!selector.isEmpty()) { requestUri = HttpHelpers.addQueryParam(requestUri, VERSION_QUERY_PARAM, String.valueOf(selector.getVersion())); } @@ -131,6 +131,8 @@ public void onResponse(@Nonnull Call call, @Nonnull Response response) { } } + // If the code makes it here, then the body should not be empty. + // If it is, then it is a logic/implementation error. // Parse the response body if (response.body() == null) { future.completeExceptionally(new IOException("Response body is null")); @@ -143,7 +145,7 @@ public void onResponse(@Nonnull Call call, @Nonnull Response response) { List events = FDv2Event.parseEventsArray(responseBody); // Create and return the response - FDv2PollingResponse pollingResponse = new FDv2PollingResponse(events, response.headers()); + FDv2PayloadResponse pollingResponse = new FDv2PayloadResponse(events, response.headers()); future.complete(pollingResponse); } catch (IOException | SerializationException e) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java index 3a489d9d..adbd1245 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java @@ -169,6 +169,7 @@ private void runSynchronizers() { } private void runInitializers() { + boolean anyDataReceived = false; for (InitializerFactory factory : initializers) { try { Initializer initializer = factory.build(); @@ -179,8 +180,12 @@ private void runInitializers() { switch (res.getResultType()) { case CHANGE_SET: // TODO: Apply to the store. - dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); - startFuture.complete(true); + anyDataReceived = true; + if(!res.getChangeSet().getSelector().isEmpty()) { + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); + startFuture.complete(true); + return; + } return; case STATUS: // TODO: Implement. @@ -189,6 +194,12 @@ private void runInitializers() { } catch (ExecutionException | InterruptedException | CancellationException e) { // TODO: Log. } + // We received data without a selector, and we have exhausted initializers, so we are going to + // conside ourselves initialized. + if(anyDataReceived) { + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); + startFuture.complete(true); + } } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2Requestor.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2Requestor.java index 8e5c9df7..8a2297e4 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2Requestor.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2Requestor.java @@ -13,11 +13,16 @@ * Interface for making FDv2 polling requests. */ interface FDv2Requestor { - public static class FDv2PollingResponse { + /** + * Response for a set of FDv2 events that result in a payload. Either a full payload or the events required + * to get from one payload version to another. + * This isn't intended for use for implementations which may require multiple executions to get an entire payload. + */ + public static class FDv2PayloadResponse { private final List events; private final Headers headers; - public FDv2PollingResponse(List events, Headers headers) { + public FDv2PayloadResponse(List events, Headers headers) { this.events = events; this.headers = headers; } @@ -30,7 +35,7 @@ public Headers getHeaders() { return headers; } } - CompletableFuture Poll(Selector selector); + CompletableFuture Poll(Selector selector); void close(); } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFDv2RequestorTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFDv2RequestorTest.java index c908e511..ea3a77a7 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFDv2RequestorTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFDv2RequestorTest.java @@ -11,7 +11,6 @@ import org.junit.Test; -import java.lang.reflect.Method; import java.net.URI; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -98,10 +97,10 @@ public void successfulRequestWithEvents() throws Exception { try (HttpServer server = HttpServer.start(resp)) { try (DefaultFDv2Requestor requestor = makeRequestor(server)) { - CompletableFuture future = + CompletableFuture future = requestor.Poll(Selector.EMPTY); - FDv2Requestor.FDv2PollingResponse response = future.get(5, TimeUnit.SECONDS); + FDv2Requestor.FDv2PayloadResponse response = future.get(5, TimeUnit.SECONDS); assertNotNull(response); assertNotNull(response.getEvents()); @@ -124,10 +123,10 @@ public void emptyEventsArray() throws Exception { try (HttpServer server = HttpServer.start(resp)) { try (DefaultFDv2Requestor requestor = makeRequestor(server)) { - CompletableFuture future = + CompletableFuture future = requestor.Poll(Selector.EMPTY); - FDv2Requestor.FDv2PollingResponse response = future.get(5, TimeUnit.SECONDS); + FDv2Requestor.FDv2PayloadResponse response = future.get(5, TimeUnit.SECONDS); assertNotNull(response); assertNotNull(response.getEvents()); @@ -144,7 +143,7 @@ public void requestWithVersionQueryParameter() throws Exception { try (DefaultFDv2Requestor requestor = makeRequestor(server)) { Selector selector = Selector.make(42, null); - CompletableFuture future = + CompletableFuture future = requestor.Poll(selector); future.get(5, TimeUnit.SECONDS); @@ -164,7 +163,7 @@ public void requestWithStateQueryParameter() throws Exception { try (DefaultFDv2Requestor requestor = makeRequestor(server)) { Selector selector = Selector.make(0, "test-state"); - CompletableFuture future = + CompletableFuture future = requestor.Poll(selector); future.get(5, TimeUnit.SECONDS); @@ -184,7 +183,7 @@ public void requestWithBothQueryParameters() throws Exception { try (DefaultFDv2Requestor requestor = makeRequestor(server)) { Selector selector = Selector.make(100, "my-state"); - CompletableFuture future = + CompletableFuture future = requestor.Poll(selector); future.get(5, TimeUnit.SECONDS); @@ -209,10 +208,10 @@ public void etagCachingWith304NotModified() throws Exception { try (HttpServer server = HttpServer.start(sequence)) { try (DefaultFDv2Requestor requestor = makeRequestor(server)) { // First request should succeed and cache the ETag - CompletableFuture future1 = + CompletableFuture future1 = requestor.Poll(Selector.EMPTY); - FDv2Requestor.FDv2PollingResponse response1 = future1.get(5, TimeUnit.SECONDS); + FDv2Requestor.FDv2PayloadResponse response1 = future1.get(5, TimeUnit.SECONDS); assertNotNull(response1); assertEquals(3, response1.getEvents().size()); @@ -221,10 +220,10 @@ public void etagCachingWith304NotModified() throws Exception { assertEquals(null, req1.getHeader("If-None-Match")); // Second request should send If-None-Match and receive 304 - CompletableFuture future2 = + CompletableFuture future2 = requestor.Poll(Selector.EMPTY); - FDv2Requestor.FDv2PollingResponse response2 = future2.get(5, TimeUnit.SECONDS); + FDv2Requestor.FDv2PayloadResponse response2 = future2.get(5, TimeUnit.SECONDS); assertEquals(null, response2); RequestInfo req2 = server.getRecorder().requireRequest(); @@ -302,7 +301,7 @@ public void httpErrorCodeThrowsException() throws Exception { try (HttpServer server = HttpServer.start(resp)) { try (DefaultFDv2Requestor requestor = makeRequestor(server)) { - CompletableFuture future = + CompletableFuture future = requestor.Poll(Selector.EMPTY); try { @@ -322,7 +321,7 @@ public void http404ThrowsException() throws Exception { try (HttpServer server = HttpServer.start(resp)) { try (DefaultFDv2Requestor requestor = makeRequestor(server)) { - CompletableFuture future = + CompletableFuture future = requestor.Poll(Selector.EMPTY); try { @@ -342,7 +341,7 @@ public void invalidJsonThrowsException() throws Exception { try (HttpServer server = HttpServer.start(resp)) { try (DefaultFDv2Requestor requestor = makeRequestor(server)) { - CompletableFuture future = + CompletableFuture future = requestor.Poll(Selector.EMPTY); try { @@ -361,7 +360,7 @@ public void missingEventsPropertyThrowsException() throws Exception { try (HttpServer server = HttpServer.start(resp)) { try (DefaultFDv2Requestor requestor = makeRequestor(server)) { - CompletableFuture future = + CompletableFuture future = requestor.Poll(Selector.EMPTY); try { @@ -384,7 +383,7 @@ public void baseUriCanHaveContextPath() throws Exception { try (DefaultFDv2Requestor requestor = new DefaultFDv2Requestor( makeHttpConfig(LDConfig.DEFAULT), uri, REQUEST_PATH, testLogger)) { - CompletableFuture future = + CompletableFuture future = requestor.Poll(Selector.EMPTY); future.get(5, TimeUnit.SECONDS); @@ -434,10 +433,10 @@ public void responseHeadersAreIncluded() throws Exception { try (HttpServer server = HttpServer.start(resp)) { try (DefaultFDv2Requestor requestor = makeRequestor(server)) { - CompletableFuture future = + CompletableFuture future = requestor.Poll(Selector.EMPTY); - FDv2Requestor.FDv2PollingResponse response = future.get(5, TimeUnit.SECONDS); + FDv2Requestor.FDv2PayloadResponse response = future.get(5, TimeUnit.SECONDS); assertNotNull(response); assertNotNull(response.getHeaders()); diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java index dd8c38a7..42e4b4b7 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java @@ -11,7 +11,6 @@ import java.io.IOException; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertEquals; @@ -43,7 +42,7 @@ private CompletableFuture failedFuture(Throwable ex) { return future; } - private FDv2Requestor.FDv2PollingResponse makeSuccessResponse() { + private FDv2Requestor.FDv2PayloadResponse makeSuccessResponse() { String json = "{\n" + " \"events\": [\n" + " {\n" + @@ -68,7 +67,7 @@ private FDv2Requestor.FDv2PollingResponse makeSuccessResponse() { "}"; try { - return new FDv2Requestor.FDv2PollingResponse( + return new FDv2Requestor.FDv2PayloadResponse( com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(json), okhttp3.Headers.of() ); @@ -82,7 +81,7 @@ public void successfulInitialization() throws Exception { FDv2Requestor requestor = mockRequestor(); SelectorSource selectorSource = mockSelectorSource(); - FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); + FDv2Requestor.FDv2PayloadResponse response = makeSuccessResponse(); when(requestor.Poll(any(Selector.class))) .thenReturn(CompletableFuture.completedFuture(response)); @@ -188,7 +187,7 @@ public void shutdownBeforePollCompletes() throws Exception { FDv2Requestor requestor = mockRequestor(); SelectorSource selectorSource = mockSelectorSource(); - CompletableFuture delayedResponse = new CompletableFuture<>(); + CompletableFuture delayedResponse = new CompletableFuture<>(); when(requestor.Poll(any(Selector.class))).thenReturn(delayedResponse); PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); @@ -214,7 +213,7 @@ public void shutdownAfterPollCompletes() throws Exception { FDv2Requestor requestor = mockRequestor(); SelectorSource selectorSource = mockSelectorSource(); - FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); + FDv2Requestor.FDv2PayloadResponse response = makeSuccessResponse(); when(requestor.Poll(any(Selector.class))) .thenReturn(CompletableFuture.completedFuture(response)); @@ -249,7 +248,7 @@ public void errorEventInResponse() throws Exception { " ]\n" + "}"; - FDv2Requestor.FDv2PollingResponse response = new FDv2Requestor.FDv2PollingResponse( + FDv2Requestor.FDv2PayloadResponse response = new FDv2Requestor.FDv2PayloadResponse( com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(errorJson), okhttp3.Headers.of() ); @@ -285,7 +284,7 @@ public void goodbyeEventInResponse() throws Exception { " ]\n" + "}"; - FDv2Requestor.FDv2PollingResponse response = new FDv2Requestor.FDv2PollingResponse( + FDv2Requestor.FDv2PayloadResponse response = new FDv2Requestor.FDv2PayloadResponse( com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(goodbyeJson), okhttp3.Headers.of() ); @@ -312,7 +311,7 @@ public void emptyEventsArray() throws Exception { String emptyJson = "{\"events\": []}"; - FDv2Requestor.FDv2PollingResponse response = new FDv2Requestor.FDv2PollingResponse( + FDv2Requestor.FDv2PayloadResponse response = new FDv2Requestor.FDv2PayloadResponse( com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(emptyJson), okhttp3.Headers.of() ); diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java index a4e1add4..ce8fa6f9 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java @@ -42,7 +42,7 @@ private CompletableFuture failedFuture(Throwable ex) { return future; } - private FDv2Requestor.FDv2PollingResponse makeSuccessResponse() { + private FDv2Requestor.FDv2PayloadResponse makeSuccessResponse() { String json = "{\n" + " \"events\": [\n" + " {\n" + @@ -67,7 +67,7 @@ private FDv2Requestor.FDv2PollingResponse makeSuccessResponse() { "}"; try { - return new FDv2Requestor.FDv2PollingResponse( + return new FDv2Requestor.FDv2PayloadResponse( com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(json), okhttp3.Headers.of() ); @@ -83,7 +83,7 @@ public void nextWaitsWhenQueueEmpty() throws Exception { ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); // Delay the response so queue is initially empty - CompletableFuture delayedResponse = new CompletableFuture<>(); + CompletableFuture delayedResponse = new CompletableFuture<>(); when(requestor.Poll(any(Selector.class))).thenReturn(delayedResponse); try { @@ -121,7 +121,7 @@ public void nextReturnsImmediatelyWhenResultQueued() throws Exception { SelectorSource selectorSource = mockSelectorSource(); ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); - FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); + FDv2Requestor.FDv2PayloadResponse response = makeSuccessResponse(); when(requestor.Poll(any(Selector.class))) .thenReturn(CompletableFuture.completedFuture(response)); @@ -157,7 +157,7 @@ public void multipleItemsQueuedReturnedInOrder() throws Exception { SelectorSource selectorSource = mockSelectorSource(); ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); - FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); + FDv2Requestor.FDv2PayloadResponse response = makeSuccessResponse(); when(requestor.Poll(any(Selector.class))) .thenReturn(CompletableFuture.completedFuture(response)); @@ -198,7 +198,7 @@ public void shutdownBeforeNextCalled() throws Exception { SelectorSource selectorSource = mockSelectorSource(); ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); - FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); + FDv2Requestor.FDv2PayloadResponse response = makeSuccessResponse(); when(requestor.Poll(any(Selector.class))) .thenReturn(CompletableFuture.completedFuture(response)); @@ -231,7 +231,7 @@ public void shutdownWhileNextWaiting() throws Exception { ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); // Delay the response so next() will be waiting - CompletableFuture delayedResponse = new CompletableFuture<>(); + CompletableFuture delayedResponse = new CompletableFuture<>(); when(requestor.Poll(any(Selector.class))).thenReturn(delayedResponse); try { @@ -268,7 +268,7 @@ public void shutdownAfterMultipleItemsQueued() throws Exception { SelectorSource selectorSource = mockSelectorSource(); ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); - FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); + FDv2Requestor.FDv2PayloadResponse response = makeSuccessResponse(); when(requestor.Poll(any(Selector.class))) .thenReturn(CompletableFuture.completedFuture(response)); @@ -489,7 +489,7 @@ public void multipleConsumersCanCallNext() throws Exception { SelectorSource selectorSource = mockSelectorSource(); ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); - FDv2Requestor.FDv2PollingResponse response = makeSuccessResponse(); + FDv2Requestor.FDv2PayloadResponse response = makeSuccessResponse(); when(requestor.Poll(any(Selector.class))) .thenReturn(CompletableFuture.completedFuture(response)); From 707fe0e0134e3951727f6b80bf6b0add9885dcaf Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:25:12 -0800 Subject: [PATCH 24/48] Implement more shutdown logic. --- .../sdk/server/FDv2DataSource.java | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java index adbd1245..2e124a43 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java @@ -27,8 +27,12 @@ class FDv2DataSource implements DataSource { private final CompletableFuture startFuture = new CompletableFuture<>(); private final AtomicBoolean started = new AtomicBoolean(false); + /** + * Lock for active sources and shutdown state. + */ private final Object activeSourceLock = new Object(); private DataSourceShutdown activeSource; + private boolean isShutdown = false; private static class SynchronizerFactoryWithState { public enum State { @@ -130,8 +134,13 @@ private void runSynchronizers() { // recovering ones, then we likely will want to wait for them to be available (or bypass recovery). while (availableSynchronizer != null) { Synchronizer synchronizer = availableSynchronizer.build(); + synchronized (activeSourceLock) { + if (isShutdown) { + return; + } + activeSource = synchronizer; + } try { - boolean running = true; while (running) { FDv2SourceResult result = synchronizer.next().get(); @@ -174,6 +183,9 @@ private void runInitializers() { try { Initializer initializer = factory.build(); synchronized (activeSourceLock) { + if (isShutdown) { + return; + } activeSource = initializer; } FDv2SourceResult res = initializer.run().get(); @@ -181,7 +193,7 @@ private void runInitializers() { case CHANGE_SET: // TODO: Apply to the store. anyDataReceived = true; - if(!res.getChangeSet().getSelector().isEmpty()) { + if (!res.getChangeSet().getSelector().isEmpty()) { dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); startFuture.complete(true); return; @@ -194,12 +206,12 @@ private void runInitializers() { } catch (ExecutionException | InterruptedException | CancellationException e) { // TODO: Log. } - // We received data without a selector, and we have exhausted initializers, so we are going to - // conside ourselves initialized. - if(anyDataReceived) { - dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); - startFuture.complete(true); - } + } + // We received data without a selector, and we have exhausted initializers, so we are going to + // consider ourselves initialized. + if (anyDataReceived) { + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); + startFuture.complete(true); } } @@ -229,7 +241,12 @@ public void close() throws IOException { synchronizer.block(); } } + // If there is an active source, we will shut it down, and that will result in the loop handling that source + // exiting. + // If we do not have an active source, then the loop will check isShutdown when attempting to set one. When + // it detects shutdown it will exit the loop. synchronized (activeSourceLock) { + isShutdown = true; if (activeSource != null) { activeSource.shutdown(); } From cb79f5e8c923271d3775e280f08b5333ec1aa594 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:41:16 -0800 Subject: [PATCH 25/48] Change null check. --- .../sdk/server/DefaultFDv2Requestor.java | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java index 51fcf938..6cb391d6 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java @@ -23,6 +23,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.CompletableFuture; /** @@ -131,15 +132,9 @@ public void onResponse(@Nonnull Call call, @Nonnull Response response) { } } - // If the code makes it here, then the body should not be empty. - // If it is, then it is a logic/implementation error. - // Parse the response body - if (response.body() == null) { - future.completeExceptionally(new IOException("Response body is null")); - return; - } - - String responseBody = response.body().string(); + // The documentation indicates that the body will not be null for a response passed to the + // onResponse callback. + String responseBody = Objects.requireNonNull(response.body()).string(); logger.debug("Received FDv2 polling response"); List events = FDv2Event.parseEventsArray(responseBody); From 0aba424d50fd8865382556786161d0a988b446ef Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 14 Jan 2026 15:58:35 -0800 Subject: [PATCH 26/48] chore: Implement streaming synchronizer. --- .../sdk/server/StreamingSynchronizerImpl.java | 32 ++++--------------- .../com/launchdarkly/sdk/server/Version.java | 2 -- .../server/StreamingSynchronizerImplTest.java | 31 ------------------ 3 files changed, 6 insertions(+), 59 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java index aa5f764b..c4a02cb9 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java @@ -147,6 +147,8 @@ private void startStream() { } }); streamThread.setName("LaunchDarkly-FDv2-streaming-synchronizer"); + // TODO: Implement thread priority. + //streamThread.setPriority(); streamThread.setDaemon(true); streamThread.start(); } @@ -185,7 +187,7 @@ private boolean handleEvent(StreamEvent event) { } private void handleMessage(MessageEvent event) { - String eventName = null; + String eventName; try { eventName = event.getEventName(); FDv2Event fdv2Event = parseFDv2Event(eventName, event.getDataReader()); @@ -238,31 +240,10 @@ private void handleMessage(MessageEvent event) { break; case ERROR: + // In the case of an error, the protocol handler discards the result and we remain connected. + // We log the error to help with debugging. FDv2ProtocolHandler.FDv2ActionError error = (FDv2ProtocolHandler.FDv2ActionError) action; - // Check if this is an explicit error event from the server (terminal) - // or invalid data in another event type (recoverable) - if ("error".equals(eventName)) { - DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( - DataSourceStatusProvider.ErrorKind.UNKNOWN, - 0, - error.getReason(), - Instant.now() - ); - result = FDv2SourceResult.terminalError(errorInfo); - shouldTerminate = true; - } else { - // Invalid data in other event types - recoverable - DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( - DataSourceStatusProvider.ErrorKind.INVALID_DATA, - 0, - error.getReason(), - Instant.now() - ); - result = FDv2SourceResult.interrupted(errorInfo); - if (eventSource != null) { - eventSource.interrupt(); // restart the stream - } - } + logger.error("Received error from server: {} - {}", error.getId(), error.getReason()); break; case GOODBYE: @@ -297,7 +278,6 @@ private void handleMessage(MessageEvent event) { if (eventSource != null) { eventSource.close(); } - return; } } catch (SerializationException e) { logger.error("Failed to parse FDv2 event: {}", LogValues.exceptionSummary(e)); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java index 85a5238f..c92affab 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java @@ -4,7 +4,5 @@ abstract class Version { private Version() {} // This constant is updated automatically by our Gradle script during a release, if the project version has changed - // x-release-please-start-version static final String SDK_VERSION = "7.10.2"; - // x-release-please-end } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java index 7fcbd560..c5c22479 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java @@ -261,37 +261,6 @@ public void shutdownAfterEventReceived() throws Exception { } } - @Test - public void errorEventInResponse() throws Exception { - String errorEvent = makeEvent("error", "{\"error\":\"invalid-request\",\"reason\":\"bad request\"}"); - - try (HttpServer server = HttpServer.start(Handlers.all( - Handlers.SSE.start(), - Handlers.SSE.event(errorEvent), - Handlers.SSE.leaveOpen()))) { - - HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); - SelectorSource selectorSource = mockSelectorSource(); - - StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( - httpProperties, - server.getUri(), - "/stream", - testLogger, - selectorSource - ); - - CompletableFuture resultFuture = synchronizer.next(); - FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); - - assertNotNull(result); - assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); - assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); - - synchronizer.shutdown(); - } - } - @Test public void goodbyeEventInResponse() throws Exception { String goodbyeEvent = makeEvent("goodbye", "{\"reason\":\"service-unavailable\"}"); From 91d2cb908b88ff6e7816e0a1bcee698ed9fa828a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Jan 2026 08:59:14 -0800 Subject: [PATCH 27/48] WIP --- .../sdk/server/FDv2DataSource.java | 21 +-- .../integrations/DataSystemBuilder.java | 41 +++--- .../integrations/DataSystemComponents.java | 29 ++-- .../server/integrations/DataSystemModes.java | 12 +- ...ava => FDv2PollingInitializerBuilder.java} | 57 ++++---- .../FDv2PollingSynchronizerBuilder.java | 129 ++++++++++++++++++ ... => FDv2StreamingSynchronizerBuilder.java} | 54 ++++---- .../subsystems/DataSystemConfiguration.java | 30 ++-- .../sdk/server/ConfigurationTest.java | 79 +++++------ 9 files changed, 288 insertions(+), 164 deletions(-) rename lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/{FDv2PollingDataSourceBuilder.java => FDv2PollingInitializerBuilder.java} (70%) create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingSynchronizerBuilder.java rename lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/{FDv2StreamingDataSourceBuilder.java => FDv2StreamingSynchronizerBuilder.java} (73%) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java index 2e124a43..2ae1e279 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java @@ -45,12 +45,6 @@ public enum State { * This synchronizer is no longer available to use. */ Blocked, - - /** - * This synchronizer is recovering from a previous failure and will be available to use - * after a delay. - */ - Recovering } private final SynchronizerFactory factory; @@ -70,11 +64,6 @@ public void block() { state = State.Blocked; } - public void setRecovering(Duration delay) { - state = State.Recovering; - // TODO: Determine how/when to recover. - } - public Synchronizer build() { return factory.build(); } @@ -234,13 +223,6 @@ public boolean isInitialized() { @Override public void close() throws IOException { - // If this is already set, then this has no impact. - startFuture.complete(false); - synchronized (synchronizers) { - for (SynchronizerFactoryWithState synchronizer : synchronizers) { - synchronizer.block(); - } - } // If there is an active source, we will shut it down, and that will result in the loop handling that source // exiting. // If we do not have an active source, then the loop will check isShutdown when attempting to set one. When @@ -251,5 +233,8 @@ public void close() throws IOException { activeSource.shutdown(); } } + + // If this is already set, then this has no impact. + startFuture.complete(false); } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java index cf063777..7f4490ec 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java @@ -1,8 +1,9 @@ package com.launchdarkly.sdk.server.integrations; import com.google.common.collect.ImmutableList; +import com.launchdarkly.sdk.server.datasources.Initializer; +import com.launchdarkly.sdk.server.datasources.Synchronizer; import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; -import com.launchdarkly.sdk.server.subsystems.DataSource; import com.launchdarkly.sdk.server.subsystems.DataStore; import com.launchdarkly.sdk.server.subsystems.DataSystemConfiguration; @@ -18,21 +19,22 @@ */ public final class DataSystemBuilder { - private final List> initializers = new ArrayList<>(); - private final List> synchronizers = new ArrayList<>(); - private ComponentConfigurer fDv1FallbackSynchronizer; + private final List> initializers = new ArrayList<>(); + private final List> synchronizers = new ArrayList<>(); + private ComponentConfigurer fDv1FallbackSynchronizer; private ComponentConfigurer persistentStore; private DataSystemConfiguration.DataStoreMode persistentDataStoreMode; /** * Add one or more initializers to the builder. * To replace initializers, please refer to {@link #replaceInitializers(ComponentConfigurer[])}. - * + * * @param initializers the initializers to add * @return a reference to the builder */ - public DataSystemBuilder initializers(ComponentConfigurer... initializers) { - for (ComponentConfigurer initializer : initializers) { + @SafeVarargs + public final DataSystemBuilder initializers(ComponentConfigurer... initializers) { + for (ComponentConfigurer initializer : initializers) { this.initializers.add(initializer); } return this; @@ -41,13 +43,14 @@ public DataSystemBuilder initializers(ComponentConfigurer... initial /** * Replaces any existing initializers with the given initializers. * To add initializers, please refer to {@link #initializers(ComponentConfigurer[])}. - * + * * @param initializers the initializers to replace the current initializers with * @return a reference to this builder */ - public DataSystemBuilder replaceInitializers(ComponentConfigurer... initializers) { + @SafeVarargs + public final DataSystemBuilder replaceInitializers(ComponentConfigurer... initializers) { this.initializers.clear(); - for (ComponentConfigurer initializer : initializers) { + for (ComponentConfigurer initializer : initializers) { this.initializers.add(initializer); } return this; @@ -56,12 +59,13 @@ public DataSystemBuilder replaceInitializers(ComponentConfigurer... /** * Add one or more synchronizers to the builder. * To replace synchronizers, please refer to {@link #replaceSynchronizers(ComponentConfigurer[])}. - * + * * @param synchronizers the synchronizers to add * @return a reference to the builder */ - public DataSystemBuilder synchronizers(ComponentConfigurer... synchronizers) { - for (ComponentConfigurer synchronizer : synchronizers) { + @SafeVarargs + public final DataSystemBuilder synchronizers(ComponentConfigurer... synchronizers) { + for (ComponentConfigurer synchronizer : synchronizers) { this.synchronizers.add(synchronizer); } return this; @@ -70,13 +74,14 @@ public DataSystemBuilder synchronizers(ComponentConfigurer... synchr /** * Replaces any existing synchronizers with the given synchronizers. * To add synchronizers, please refer to {@link #synchronizers(ComponentConfigurer[])}. - * + * * @param synchronizers the synchronizers to replace the current synchronizers with * @return a reference to this builder */ - public DataSystemBuilder replaceSynchronizers(ComponentConfigurer... synchronizers) { + @SafeVarargs + public final DataSystemBuilder replaceSynchronizers(ComponentConfigurer... synchronizers) { this.synchronizers.clear(); - for (ComponentConfigurer synchronizer : synchronizers) { + for (ComponentConfigurer synchronizer : synchronizers) { this.synchronizers.add(synchronizer); } return this; @@ -87,11 +92,11 @@ public DataSystemBuilder replaceSynchronizers(ComponentConfigurer... *

* LaunchDarkly can instruct the SDK to fall back to this synchronizer. *

- * + * * @param fDv1FallbackSynchronizer the FDv1 fallback synchronizer * @return a reference to the builder */ - public DataSystemBuilder fDv1FallbackSynchronizer(ComponentConfigurer fDv1FallbackSynchronizer) { + public DataSystemBuilder fDv1FallbackSynchronizer(ComponentConfigurer fDv1FallbackSynchronizer) { this.fDv1FallbackSynchronizer = fDv1FallbackSynchronizer; return this; } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemComponents.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemComponents.java index 43d58548..e26f5585 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemComponents.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemComponents.java @@ -14,21 +14,30 @@ public final class DataSystemComponents { private DataSystemComponents() {} /** - * Get a builder for a polling data source. - * - * @return the polling data source builder + * Get a builder for a polling initializer. + * + * @return the polling initializer builder */ - public static FDv2PollingDataSourceBuilder polling() { - return new FDv2PollingDataSourceBuilder(); + public static FDv2PollingInitializerBuilder pollingInitializer() { + return new FDv2PollingInitializerBuilder(); } /** - * Get a builder for a streaming data source. - * - * @return the streaming data source builder + * Get a builder for a polling synchronizer. + * + * @return the polling synchronizer builder + */ + public static FDv2PollingSynchronizerBuilder pollingSynchronizer() { + return new FDv2PollingSynchronizerBuilder(); + } + + /** + * Get a builder for a streaming synchronizer. + * + * @return the streaming synchronizer builder */ - public static FDv2StreamingDataSourceBuilder streaming() { - return new FDv2StreamingDataSourceBuilder(); + public static FDv2StreamingSynchronizerBuilder streamingSynchronizer() { + return new FDv2StreamingSynchronizerBuilder(); } /** diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemModes.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemModes.java index 82544157..1d34f4dd 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemModes.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemModes.java @@ -39,8 +39,8 @@ public final class DataSystemModes { */ public DataSystemBuilder defaultMode() { return custom() - .initializers(DataSystemComponents.polling()) - .synchronizers(DataSystemComponents.streaming(), DataSystemComponents.polling()) + .initializers(DataSystemComponents.pollingInitializer()) + .synchronizers(DataSystemComponents.streamingSynchronizer(), DataSystemComponents.pollingSynchronizer()) .fDv1FallbackSynchronizer(DataSystemComponents.fDv1Polling()); } @@ -66,7 +66,7 @@ public DataSystemBuilder defaultMode() { */ public DataSystemBuilder streaming() { return custom() - .synchronizers(DataSystemComponents.streaming()) + .synchronizers(DataSystemComponents.streamingSynchronizer()) .fDv1FallbackSynchronizer(DataSystemComponents.fDv1Polling()); } @@ -88,7 +88,7 @@ public DataSystemBuilder streaming() { */ public DataSystemBuilder polling() { return custom() - .synchronizers(DataSystemComponents.polling()) + .synchronizers(DataSystemComponents.pollingSynchronizer()) .fDv1FallbackSynchronizer(DataSystemComponents.fDv1Polling()); } @@ -144,8 +144,8 @@ public DataSystemBuilder persistentStore(ComponentConfigurer persiste *

    *     LDConfig config = new LDConfig.Builder("my-sdk-key")
    *       .dataSystem(Components.dataSystem().custom()
-   *         .initializers(DataSystemComponents.polling())
-   *         .synchronizers(DataSystemComponents.streaming(), DataSystemComponents.polling())
+   *         .initializers(DataSystemComponents.pollingInitializer())
+   *         .synchronizers(DataSystemComponents.streamingSynchronizer(), DataSystemComponents.pollingSynchronizer())
    *         .fDv1FallbackSynchronizer(DataSystemComponents.fDv1Polling()));
    * 
* diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingDataSourceBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingInitializerBuilder.java similarity index 70% rename from lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingDataSourceBuilder.java rename to lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingInitializerBuilder.java index 81a40aae..a824ae90 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingDataSourceBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingInitializerBuilder.java @@ -3,17 +3,17 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.internal.events.DiagnosticConfigProperty; import com.launchdarkly.sdk.server.StandardEndpoints; +import com.launchdarkly.sdk.server.datasources.Initializer; import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; import com.launchdarkly.sdk.server.subsystems.ClientContext; import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; -import com.launchdarkly.sdk.server.subsystems.DataSource; import com.launchdarkly.sdk.server.subsystems.DiagnosticDescription; import java.net.URI; import java.time.Duration; /** - * Contains methods for configuring the polling data source. + * Contains methods for configuring the polling initializer. *

* This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. * It is in early access. If you want access to this feature please join the EAP. https://launchdarkly.com/docs/sdk/features/data-saving-mode @@ -24,14 +24,14 @@ *


  *     LDConfig config = new LDConfig.Builder("my-sdk-key")
  *         .dataSystem(Components.dataSystem().custom()
- *             // DataSystemComponents.polling() returns an instance of this builder.
- *             .initializers(DataSystemComponents.polling()
+ *             // DataSystemComponents.pollingInitializer() returns an instance of this builder.
+ *             .initializers(DataSystemComponents.pollingInitializer()
  *                 .pollInterval(Duration.ofMinutes(10)))
- *             .synchronizers(DataSystemComponents.streaming(), DataSystemComponents.polling())
+ *             .synchronizers(DataSystemComponents.streamingSynchronizer(), DataSystemComponents.pollingSynchronizer())
  *             .fDv1FallbackSynchronizer(DataSystemComponents.fDv1Polling()));
  * 
*/ -public final class FDv2PollingDataSourceBuilder implements ComponentConfigurer, DiagnosticDescription { +public final class FDv2PollingInitializerBuilder implements ComponentConfigurer, DiagnosticDescription { /** * The default value for {@link #pollInterval(Duration)}: 30 seconds. */ @@ -47,11 +47,11 @@ public final class FDv2PollingDataSourceBuilder implements ComponentConfigurer - * + * * @param pollInterval the polling interval * @return the builder */ - public FDv2PollingDataSourceBuilder pollInterval(Duration pollInterval) { + public FDv2PollingInitializerBuilder pollInterval(Duration pollInterval) { this.pollInterval = pollInterval != null && pollInterval.compareTo(DEFAULT_POLL_INTERVAL) >= 0 ? pollInterval : DEFAULT_POLL_INTERVAL; @@ -60,30 +60,30 @@ public FDv2PollingDataSourceBuilder pollInterval(Duration pollInterval) { /** * Exposed internally for testing. - * + * * @param pollInterval the polling interval * @return the builder */ - FDv2PollingDataSourceBuilder pollIntervalNoMinimum(Duration pollInterval) { + FDv2PollingInitializerBuilder pollIntervalNoMinimum(Duration pollInterval) { this.pollInterval = pollInterval; return this; } /** - * Sets overrides for the service endpoints. In typical usage, the data source will use the commonly defined + * Sets overrides for the service endpoints. In typical usage, the initializer will use the commonly defined * service endpoints, but for cases where they need to be controlled at the source level, this method can - * be used. This data source will only use the endpoints applicable to it. - * + * be used. This initializer will only use the endpoints applicable to it. + * * @param serviceEndpointsOverride the service endpoints to override the base endpoints * @return the builder */ - public FDv2PollingDataSourceBuilder serviceEndpointsOverride(ServiceEndpointsBuilder serviceEndpointsOverride) { + public FDv2PollingInitializerBuilder serviceEndpointsOverride(ServiceEndpointsBuilder serviceEndpointsOverride) { this.serviceEndpointsOverride = serviceEndpointsOverride.createServiceEndpoints(); return this; } @Override - public DataSource build(ClientContext context) { + public Initializer build(ClientContext context) { ServiceEndpoints endpoints = serviceEndpointsOverride != null ? serviceEndpointsOverride : context.getServiceEndpoints(); @@ -93,20 +93,18 @@ public DataSource build(ClientContext context) { "Polling", context.getBaseLogger()); - // TODO: Implement FDv2PollingRequestor - // var requestor = new FDv2PollingRequestor(context, configuredBaseUri); - - // TODO: Implement FDv2PollingDataSource - // return new FDv2PollingDataSource( - // context, - // context.getDataSourceUpdateSink(), + // TODO: Implement FDv2Requestor + // var requestor = new FDv2RequestorImpl(context, configuredBaseUri); + + // TODO: Implement PollingInitializer with FDv2Requestor + // return new PollingInitializerImpl( // requestor, - // pollInterval, - // () -> context.getSelectorSource() != null ? context.getSelectorSource().getSelector() : Selector.empty() + // context.getBaseLogger(), + // context.getSelectorSource() // ); - - // Placeholder - this will not compile until FDv2PollingDataSource is implemented - throw new UnsupportedOperationException("FDv2PollingDataSource is not yet implemented"); + + // Placeholder - this will not compile until FDv2Requestor is implemented + throw new UnsupportedOperationException("FDv2Requestor is not yet implemented"); } @Override @@ -114,10 +112,10 @@ public LDValue describeConfiguration(ClientContext context) { ServiceEndpoints endpoints = serviceEndpointsOverride != null ? serviceEndpointsOverride : context.getServiceEndpoints(); - + boolean customPollingBaseUri = StandardEndpoints.isCustomBaseUri( endpoints.getPollingBaseUri(), StandardEndpoints.DEFAULT_POLLING_BASE_URI); - + return LDValue.buildObject() .put(DiagnosticConfigProperty.STREAMING_DISABLED.name, true) .put(DiagnosticConfigProperty.CUSTOM_BASE_URI.name, customPollingBaseUri) @@ -126,4 +124,3 @@ public LDValue describeConfiguration(ClientContext context) { .build(); } } - diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingSynchronizerBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingSynchronizerBuilder.java new file mode 100644 index 00000000..22510311 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingSynchronizerBuilder.java @@ -0,0 +1,129 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.internal.events.DiagnosticConfigProperty; +import com.launchdarkly.sdk.server.StandardEndpoints; +import com.launchdarkly.sdk.server.datasources.Synchronizer; +import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; +import com.launchdarkly.sdk.server.subsystems.ClientContext; +import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.server.subsystems.DiagnosticDescription; + +import java.net.URI; +import java.time.Duration; + +/** + * Contains methods for configuring the polling synchronizer. + *

+ * This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. + * It is in early access. If you want access to this feature please join the EAP. https://launchdarkly.com/docs/sdk/features/data-saving-mode + *

+ *

+ * Example: + *

+ *

+ *     LDConfig config = new LDConfig.Builder("my-sdk-key")
+ *         .dataSystem(Components.dataSystem().custom()
+ *             .initializers(DataSystemComponents.pollingInitializer())
+ *             // DataSystemComponents.pollingSynchronizer() returns an instance of this builder.
+ *             .synchronizers(DataSystemComponents.streamingSynchronizer(),
+ *                 DataSystemComponents.pollingSynchronizer()
+ *                     .pollInterval(Duration.ofMinutes(10)))
+ *             .fDv1FallbackSynchronizer(DataSystemComponents.fDv1Polling()));
+ * 
+ */ +public final class FDv2PollingSynchronizerBuilder implements ComponentConfigurer, DiagnosticDescription { + /** + * The default value for {@link #pollInterval(Duration)}: 30 seconds. + */ + public static final Duration DEFAULT_POLL_INTERVAL = Duration.ofSeconds(30); + + Duration pollInterval = DEFAULT_POLL_INTERVAL; + + private ServiceEndpoints serviceEndpointsOverride; + + /** + * Sets the interval at which the SDK will poll for feature flag updates. + *

+ * The default and minimum value is {@link #DEFAULT_POLL_INTERVAL}. Values less than this will + * be set to the default. + *

+ * + * @param pollInterval the polling interval + * @return the builder + */ + public FDv2PollingSynchronizerBuilder pollInterval(Duration pollInterval) { + this.pollInterval = pollInterval != null && pollInterval.compareTo(DEFAULT_POLL_INTERVAL) >= 0 + ? pollInterval + : DEFAULT_POLL_INTERVAL; + return this; + } + + /** + * Exposed internally for testing. + * + * @param pollInterval the polling interval + * @return the builder + */ + FDv2PollingSynchronizerBuilder pollIntervalNoMinimum(Duration pollInterval) { + this.pollInterval = pollInterval; + return this; + } + + /** + * Sets overrides for the service endpoints. In typical usage, the synchronizer will use the commonly defined + * service endpoints, but for cases where they need to be controlled at the source level, this method can + * be used. This synchronizer will only use the endpoints applicable to it. + * + * @param serviceEndpointsOverride the service endpoints to override the base endpoints + * @return the builder + */ + public FDv2PollingSynchronizerBuilder serviceEndpointsOverride(ServiceEndpointsBuilder serviceEndpointsOverride) { + this.serviceEndpointsOverride = serviceEndpointsOverride.createServiceEndpoints(); + return this; + } + + @Override + public Synchronizer build(ClientContext context) { + ServiceEndpoints endpoints = serviceEndpointsOverride != null + ? serviceEndpointsOverride + : context.getServiceEndpoints(); + URI configuredBaseUri = StandardEndpoints.selectBaseUri( + endpoints.getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "Polling", + context.getBaseLogger()); + + // TODO: Implement FDv2Requestor + // var requestor = new FDv2RequestorImpl(context, configuredBaseUri); + + // TODO: Implement PollingSynchronizer with FDv2Requestor + // return new PollingSynchronizerImpl( + // requestor, + // context.getBaseLogger(), + // context.getSelectorSource(), + // context.getSharedExecutor(), + // pollInterval + // ); + + // Placeholder - this will not compile until FDv2Requestor is implemented + throw new UnsupportedOperationException("FDv2Requestor is not yet implemented"); + } + + @Override + public LDValue describeConfiguration(ClientContext context) { + ServiceEndpoints endpoints = serviceEndpointsOverride != null + ? serviceEndpointsOverride + : context.getServiceEndpoints(); + + boolean customPollingBaseUri = StandardEndpoints.isCustomBaseUri( + endpoints.getPollingBaseUri(), StandardEndpoints.DEFAULT_POLLING_BASE_URI); + + return LDValue.buildObject() + .put(DiagnosticConfigProperty.STREAMING_DISABLED.name, true) + .put(DiagnosticConfigProperty.CUSTOM_BASE_URI.name, customPollingBaseUri) + .put(DiagnosticConfigProperty.POLLING_INTERVAL_MILLIS.name, pollInterval.toMillis()) + .put(DiagnosticConfigProperty.USING_RELAY_DAEMON.name, false) + .build(); + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingDataSourceBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingSynchronizerBuilder.java similarity index 73% rename from lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingDataSourceBuilder.java rename to lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingSynchronizerBuilder.java index 8e08531b..5939b6be 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingDataSourceBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingSynchronizerBuilder.java @@ -3,17 +3,18 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.internal.events.DiagnosticConfigProperty; import com.launchdarkly.sdk.server.StandardEndpoints; +import com.launchdarkly.sdk.server.StreamingSynchronizerImpl; +import com.launchdarkly.sdk.server.datasources.Synchronizer; import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; import com.launchdarkly.sdk.server.subsystems.ClientContext; import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; -import com.launchdarkly.sdk.server.subsystems.DataSource; import com.launchdarkly.sdk.server.subsystems.DiagnosticDescription; import java.net.URI; import java.time.Duration; /** - * Contains methods for configuring the streaming data source. + * Contains methods for configuring the streaming synchronizer. *

* This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. * It is in early access. If you want access to this feature please join the EAP. https://launchdarkly.com/docs/sdk/features/data-saving-mode @@ -24,14 +25,14 @@ *


  *     LDConfig config = new LDConfig.Builder("my-sdk-key")
  *         .dataSystem(Components.dataSystem().custom()
- *             .initializers(DataSystemComponents.polling())
- *             // DataSystemComponents.streaming() returns an instance of this builder.
- *             .synchronizers(DataSystemComponents.streaming()
- *                 .initialReconnectDelay(Duration.ofSeconds(5)), DataSystemComponents.polling())
+ *             .initializers(DataSystemComponents.pollingInitializer())
+ *             // DataSystemComponents.streamingSynchronizer() returns an instance of this builder.
+ *             .synchronizers(DataSystemComponents.streamingSynchronizer()
+ *                 .initialReconnectDelay(Duration.ofSeconds(5)), DataSystemComponents.pollingSynchronizer())
  *             .fDv1FallbackSynchronizer(DataSystemComponents.fDv1Polling()));
  * 
*/ -public final class FDv2StreamingDataSourceBuilder implements ComponentConfigurer, DiagnosticDescription { +public final class FDv2StreamingSynchronizerBuilder implements ComponentConfigurer, DiagnosticDescription { /** * The default value for {@link #initialReconnectDelay(Duration)}: 1000 milliseconds. */ @@ -51,30 +52,30 @@ public final class FDv2StreamingDataSourceBuilder implements ComponentConfigurer *

* The default value is {@link #DEFAULT_INITIAL_RECONNECT_DELAY}. *

- * + * * @param initialReconnectDelay the reconnect time base value * @return the builder */ - public FDv2StreamingDataSourceBuilder initialReconnectDelay(Duration initialReconnectDelay) { + public FDv2StreamingSynchronizerBuilder initialReconnectDelay(Duration initialReconnectDelay) { this.initialReconnectDelay = initialReconnectDelay != null ? initialReconnectDelay : DEFAULT_INITIAL_RECONNECT_DELAY; return this; } /** - * Sets overrides for the service endpoints. In typical usage, the data source will use the commonly defined + * Sets overrides for the service endpoints. In typical usage, the synchronizer will use the commonly defined * service endpoints, but for cases where they need to be controlled at the source level, this method can - * be used. This data source will only use the endpoints applicable to it. - * + * be used. This synchronizer will only use the endpoints applicable to it. + * * @param serviceEndpointsOverride the service endpoints to override the base endpoints * @return the builder */ - public FDv2StreamingDataSourceBuilder serviceEndpointsOverride(ServiceEndpointsBuilder serviceEndpointsOverride) { + public FDv2StreamingSynchronizerBuilder serviceEndpointsOverride(ServiceEndpointsBuilder serviceEndpointsOverride) { this.serviceEndpointsOverride = serviceEndpointsOverride.createServiceEndpoints(); return this; } @Override - public DataSource build(ClientContext context) { + public Synchronizer build(ClientContext context) { ServiceEndpoints endpoints = serviceEndpointsOverride != null ? serviceEndpointsOverride : context.getServiceEndpoints(); @@ -83,18 +84,14 @@ public DataSource build(ClientContext context) { StandardEndpoints.DEFAULT_STREAMING_BASE_URI, "Streaming", context.getBaseLogger()); - - // TODO: Implement FDv2StreamingDataSource - // return new FDv2StreamingDataSource( - // context, - // context.getDataSourceUpdateSink(), - // configuredBaseUri, - // initialReconnectDelay, - // () -> context.getSelectorSource() != null ? context.getSelectorSource().getSelector() : Selector.empty() - // ); - - // Placeholder - this will not compile until FDv2StreamingDataSource is implemented - throw new UnsupportedOperationException("FDv2StreamingDataSource is not yet implemented"); + + return new StreamingSynchronizerImpl( + context.getHttp(), + configuredBaseUri, + StandardEndpoints.FDV2_STREAMING_REQUEST_PATH, + context.getBaseLogger(), + context.getSelectorSource() + ); } @Override @@ -102,12 +99,12 @@ public LDValue describeConfiguration(ClientContext context) { ServiceEndpoints endpoints = serviceEndpointsOverride != null ? serviceEndpointsOverride : context.getServiceEndpoints(); - + boolean customStreamingBaseUri = StandardEndpoints.isCustomBaseUri( endpoints.getStreamingBaseUri(), StandardEndpoints.DEFAULT_STREAMING_BASE_URI); boolean customPollingBaseUri = StandardEndpoints.isCustomBaseUri( endpoints.getPollingBaseUri(), StandardEndpoints.DEFAULT_POLLING_BASE_URI); - + return LDValue.buildObject() .put(DiagnosticConfigProperty.STREAMING_DISABLED.name, false) .put(DiagnosticConfigProperty.CUSTOM_BASE_URI.name, customPollingBaseUri) @@ -117,4 +114,3 @@ public LDValue describeConfiguration(ClientContext context) { .build(); } } - diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSystemConfiguration.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSystemConfiguration.java index 489cae83..ec7cebdb 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSystemConfiguration.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSystemConfiguration.java @@ -1,6 +1,8 @@ package com.launchdarkly.sdk.server.subsystems; import com.google.common.collect.ImmutableList; +import com.launchdarkly.sdk.server.datasources.Initializer; +import com.launchdarkly.sdk.server.datasources.Synchronizer; /** * Configuration for the SDK's data acquisition and storage strategy. @@ -35,9 +37,9 @@ public enum DataStoreMode { READ_WRITE } - private final ImmutableList> initializers; - private final ImmutableList> synchronizers; - private final ComponentConfigurer fDv1FallbackSynchronizer; + private final ImmutableList> initializers; + private final ImmutableList> synchronizers; + private final ComponentConfigurer fDv1FallbackSynchronizer; private final ComponentConfigurer persistentStore; private final DataStoreMode persistentDataStoreMode; @@ -54,9 +56,9 @@ public enum DataStoreMode { * @param persistentDataStoreMode see {@link #getPersistentDataStoreMode()} */ public DataSystemConfiguration( - ImmutableList> initializers, - ImmutableList> synchronizers, - ComponentConfigurer fDv1FallbackSynchronizer, + ImmutableList> initializers, + ImmutableList> synchronizers, + ComponentConfigurer fDv1FallbackSynchronizer, ComponentConfigurer persistentStore, DataStoreMode persistentDataStoreMode) { this.initializers = initializers; @@ -67,29 +69,29 @@ public DataSystemConfiguration( } /** - * A list of factories for creating data sources for initialization. - * + * A list of factories for creating initializers for initialization. + * * @return the list of initializer configurers */ - public ImmutableList> getInitializers() { + public ImmutableList> getInitializers() { return initializers; } /** - * A list of factories for creating data sources for synchronization. - * + * A list of factories for creating synchronizers for synchronization. + * * @return the list of synchronizer configurers */ - public ImmutableList> getSynchronizers() { + public ImmutableList> getSynchronizers() { return synchronizers; } /** * A synchronizer to fall back to when FDv1 fallback has been requested. - * + * * @return the FDv1 fallback synchronizer configurer, or null */ - public ComponentConfigurer getFDv1FallbackSynchronizer() { + public ComponentConfigurer getFDv1FallbackSynchronizer() { return fDv1FallbackSynchronizer; } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/ConfigurationTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/ConfigurationTest.java index 13aa825c..d06c5302 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/ConfigurationTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/ConfigurationTest.java @@ -3,8 +3,9 @@ import com.launchdarkly.sdk.server.integrations.DataSystemBuilder; import com.launchdarkly.sdk.server.integrations.DataSystemComponents; import com.launchdarkly.sdk.server.integrations.DataSystemModes; -import com.launchdarkly.sdk.server.integrations.FDv2PollingDataSourceBuilder; -import com.launchdarkly.sdk.server.integrations.FDv2StreamingDataSourceBuilder; +import com.launchdarkly.sdk.server.integrations.FDv2PollingInitializerBuilder; +import com.launchdarkly.sdk.server.integrations.FDv2PollingSynchronizerBuilder; +import com.launchdarkly.sdk.server.integrations.FDv2StreamingSynchronizerBuilder; import com.launchdarkly.sdk.server.integrations.MockPersistentDataStore; import com.launchdarkly.sdk.server.integrations.PersistentDataStoreBuilder; import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; @@ -61,12 +62,12 @@ public void canConfigureDefaultDataSystem() { DataSystemModes modes = new DataSystemModes(); DataSystemBuilder builder = modes.defaultMode(); DataSystemConfiguration dataSystemConfig = builder.build(); - + assertEquals(1, dataSystemConfig.getInitializers().size()); - assertTrue(dataSystemConfig.getInitializers().get(0) instanceof FDv2PollingDataSourceBuilder); + assertTrue(dataSystemConfig.getInitializers().get(0) instanceof FDv2PollingInitializerBuilder); assertEquals(2, dataSystemConfig.getSynchronizers().size()); - assertTrue(dataSystemConfig.getSynchronizers().get(0) instanceof FDv2StreamingDataSourceBuilder); - assertTrue(dataSystemConfig.getSynchronizers().get(1) instanceof FDv2PollingDataSourceBuilder); + assertTrue(dataSystemConfig.getSynchronizers().get(0) instanceof FDv2StreamingSynchronizerBuilder); + assertTrue(dataSystemConfig.getSynchronizers().get(1) instanceof FDv2PollingSynchronizerBuilder); assertTrue(dataSystemConfig.getFDv1FallbackSynchronizer() instanceof PollingDataSourceBuilder); assertNull(dataSystemConfig.getPersistentStore()); } @@ -79,7 +80,7 @@ public void canConfigureStreamingDataSystem() { assertTrue(dataSystemConfig.getInitializers().isEmpty()); assertEquals(1, dataSystemConfig.getSynchronizers().size()); - assertTrue(dataSystemConfig.getSynchronizers().get(0) instanceof FDv2StreamingDataSourceBuilder); + assertTrue(dataSystemConfig.getSynchronizers().get(0) instanceof FDv2StreamingSynchronizerBuilder); assertTrue(dataSystemConfig.getFDv1FallbackSynchronizer() instanceof PollingDataSourceBuilder); assertNull(dataSystemConfig.getPersistentStore()); } @@ -89,10 +90,10 @@ public void canConfigurePollingDataSystem() { DataSystemModes modes = new DataSystemModes(); DataSystemBuilder builder = modes.polling(); DataSystemConfiguration dataSystemConfig = builder.build(); - + assertTrue(dataSystemConfig.getInitializers().isEmpty()); assertEquals(1, dataSystemConfig.getSynchronizers().size()); - assertTrue(dataSystemConfig.getSynchronizers().get(0) instanceof FDv2PollingDataSourceBuilder); + assertTrue(dataSystemConfig.getSynchronizers().get(0) instanceof FDv2PollingSynchronizerBuilder); assertTrue(dataSystemConfig.getFDv1FallbackSynchronizer() instanceof PollingDataSourceBuilder); assertNull(dataSystemConfig.getPersistentStore()); } @@ -122,16 +123,16 @@ public void canConfigurePersistentStoreDataSystem() { ComponentConfigurer storeConfigurer = TestComponents.specificComponent(mockStore); ComponentConfigurer dataStoreConfigurer = TestComponents.specificComponent( Components.persistentDataStore(storeConfigurer).build(clientContextWithDataStoreUpdateSink())); - + DataSystemModes modes = new DataSystemModes(); DataSystemBuilder builder = modes.persistentStore(dataStoreConfigurer); DataSystemConfiguration dataSystemConfig = builder.build(); - + assertEquals(1, dataSystemConfig.getInitializers().size()); - assertTrue(dataSystemConfig.getInitializers().get(0) instanceof FDv2PollingDataSourceBuilder); + assertTrue(dataSystemConfig.getInitializers().get(0) instanceof FDv2PollingInitializerBuilder); assertEquals(2, dataSystemConfig.getSynchronizers().size()); - assertTrue(dataSystemConfig.getSynchronizers().get(0) instanceof FDv2StreamingDataSourceBuilder); - assertTrue(dataSystemConfig.getSynchronizers().get(1) instanceof FDv2PollingDataSourceBuilder); + assertTrue(dataSystemConfig.getSynchronizers().get(0) instanceof FDv2StreamingSynchronizerBuilder); + assertTrue(dataSystemConfig.getSynchronizers().get(1) instanceof FDv2PollingSynchronizerBuilder); assertTrue(dataSystemConfig.getFDv1FallbackSynchronizer() instanceof PollingDataSourceBuilder); assertNotNull(dataSystemConfig.getPersistentStore()); assertEquals(DataSystemConfiguration.DataStoreMode.READ_WRITE, dataSystemConfig.getPersistentDataStoreMode()); @@ -146,21 +147,21 @@ public void canConfigureCustomDataSystemWithAllOptions() { DataSystemModes modes = new DataSystemModes(); DataSystemBuilder builder = modes.custom() - .initializers(DataSystemComponents.polling()) - .synchronizers(DataSystemComponents.streaming(), DataSystemComponents.polling()) + .initializers(DataSystemComponents.pollingInitializer()) + .synchronizers(DataSystemComponents.streamingSynchronizer(), DataSystemComponents.pollingSynchronizer()) .fDv1FallbackSynchronizer(DataSystemComponents.fDv1Polling()) .persistentStore(dataStoreConfigurer, DataSystemConfiguration.DataStoreMode.READ_WRITE); - + DataSystemConfiguration dataSystemConfig = builder.build(); // Verify initializers assertEquals(1, dataSystemConfig.getInitializers().size()); - assertTrue(dataSystemConfig.getInitializers().get(0) instanceof FDv2PollingDataSourceBuilder); + assertTrue(dataSystemConfig.getInitializers().get(0) instanceof FDv2PollingInitializerBuilder); // Verify synchronizers assertEquals(2, dataSystemConfig.getSynchronizers().size()); - assertTrue(dataSystemConfig.getSynchronizers().get(0) instanceof FDv2StreamingDataSourceBuilder); - assertTrue(dataSystemConfig.getSynchronizers().get(1) instanceof FDv2PollingDataSourceBuilder); + assertTrue(dataSystemConfig.getSynchronizers().get(0) instanceof FDv2StreamingSynchronizerBuilder); + assertTrue(dataSystemConfig.getSynchronizers().get(1) instanceof FDv2PollingSynchronizerBuilder); // Verify FDv1 fallback assertTrue(dataSystemConfig.getFDv1FallbackSynchronizer() instanceof PollingDataSourceBuilder); @@ -174,51 +175,51 @@ public void canConfigureCustomDataSystemWithAllOptions() { public void canReplaceInitializersInCustomDataSystem() { DataSystemModes modes = new DataSystemModes(); DataSystemBuilder builder = modes.custom() - .initializers(DataSystemComponents.polling()) - .replaceInitializers(DataSystemComponents.streaming()); - + .initializers(DataSystemComponents.pollingInitializer()) + .replaceInitializers(DataSystemComponents.pollingInitializer()); + DataSystemConfiguration dataSystemConfig = builder.build(); assertEquals(1, dataSystemConfig.getInitializers().size()); - assertTrue(dataSystemConfig.getInitializers().get(0) instanceof FDv2StreamingDataSourceBuilder); + assertTrue(dataSystemConfig.getInitializers().get(0) instanceof FDv2PollingInitializerBuilder); } @Test public void canReplaceSynchronizersInCustomDataSystem() { DataSystemModes modes = new DataSystemModes(); DataSystemBuilder builder = modes.custom() - .synchronizers(DataSystemComponents.polling()) - .replaceSynchronizers(DataSystemComponents.streaming()); - + .synchronizers(DataSystemComponents.pollingSynchronizer()) + .replaceSynchronizers(DataSystemComponents.streamingSynchronizer()); + DataSystemConfiguration dataSystemConfig = builder.build(); assertEquals(1, dataSystemConfig.getSynchronizers().size()); - assertTrue(dataSystemConfig.getSynchronizers().get(0) instanceof FDv2StreamingDataSourceBuilder); + assertTrue(dataSystemConfig.getSynchronizers().get(0) instanceof FDv2StreamingSynchronizerBuilder); } @Test public void canAddMultipleInitializersToCustomDataSystem() { DataSystemModes modes = new DataSystemModes(); DataSystemBuilder builder = modes.custom() - .initializers(DataSystemComponents.polling()) - .initializers(DataSystemComponents.streaming()); - + .initializers(DataSystemComponents.pollingInitializer()) + .initializers(DataSystemComponents.pollingInitializer()); + DataSystemConfiguration dataSystemConfig = builder.build(); assertEquals(2, dataSystemConfig.getInitializers().size()); - assertTrue(dataSystemConfig.getInitializers().get(0) instanceof FDv2PollingDataSourceBuilder); - assertTrue(dataSystemConfig.getInitializers().get(1) instanceof FDv2StreamingDataSourceBuilder); + assertTrue(dataSystemConfig.getInitializers().get(0) instanceof FDv2PollingInitializerBuilder); + assertTrue(dataSystemConfig.getInitializers().get(1) instanceof FDv2PollingInitializerBuilder); } @Test public void canAddMultipleSynchronizersToCustomDataSystem() { DataSystemModes modes = new DataSystemModes(); DataSystemBuilder builder = modes.custom() - .synchronizers(DataSystemComponents.polling()) - .synchronizers(DataSystemComponents.streaming()) + .synchronizers(DataSystemComponents.pollingSynchronizer()) + .synchronizers(DataSystemComponents.streamingSynchronizer()) .synchronizers(DataSystemComponents.fDv1Polling()); - + DataSystemConfiguration dataSystemConfig = builder.build(); assertEquals(3, dataSystemConfig.getSynchronizers().size()); - assertTrue(dataSystemConfig.getSynchronizers().get(0) instanceof FDv2PollingDataSourceBuilder); - assertTrue(dataSystemConfig.getSynchronizers().get(1) instanceof FDv2StreamingDataSourceBuilder); + assertTrue(dataSystemConfig.getSynchronizers().get(0) instanceof FDv2PollingSynchronizerBuilder); + assertTrue(dataSystemConfig.getSynchronizers().get(1) instanceof FDv2StreamingSynchronizerBuilder); assertTrue(dataSystemConfig.getSynchronizers().get(2) instanceof PollingDataSourceBuilder); } @@ -262,7 +263,7 @@ public void canConfigureCustomDataSystemWithReadWritePersistentStore() { DataSystemModes modes = new DataSystemModes(); DataSystemBuilder builder = modes.custom() .persistentStore(dataStoreConfigurer, DataSystemConfiguration.DataStoreMode.READ_WRITE) - .synchronizers(DataSystemComponents.streaming()); + .synchronizers(DataSystemComponents.streamingSynchronizer()); DataSystemConfiguration dataSystemConfig = builder.build(); assertNotNull(dataSystemConfig.getPersistentStore()); From d6109881fcd98320322bf83c3db948474651e975 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Jan 2026 09:46:19 -0800 Subject: [PATCH 28/48] WIP --- .../integrations/DataSystemBuilder.java | 8 ++++++-- .../FDv2StreamingSynchronizerBuilder.java | 19 +++++++++++-------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java index 7f4490ec..6fd6efd3 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java @@ -4,6 +4,7 @@ import com.launchdarkly.sdk.server.datasources.Initializer; import com.launchdarkly.sdk.server.datasources.Synchronizer; import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.server.subsystems.DataSource; import com.launchdarkly.sdk.server.subsystems.DataStore; import com.launchdarkly.sdk.server.subsystems.DataSystemConfiguration; @@ -96,8 +97,11 @@ public final DataSystemBuilder replaceSynchronizers(ComponentConfigurer fDv1FallbackSynchronizer) { - this.fDv1FallbackSynchronizer = fDv1FallbackSynchronizer; + @SuppressWarnings("unchecked") + public DataSystemBuilder fDv1FallbackSynchronizer(ComponentConfigurer fDv1FallbackSynchronizer) { + // Legacy DataSource configurers are used for FDv1 backward compatibility + // This is safe because DataSource is only used in the fallback context + this.fDv1FallbackSynchronizer = (ComponentConfigurer) (ComponentConfigurer) fDv1FallbackSynchronizer; return this; } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingSynchronizerBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingSynchronizerBuilder.java index 5939b6be..89fa8f4b 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingSynchronizerBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingSynchronizerBuilder.java @@ -3,7 +3,6 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.internal.events.DiagnosticConfigProperty; import com.launchdarkly.sdk.server.StandardEndpoints; -import com.launchdarkly.sdk.server.StreamingSynchronizerImpl; import com.launchdarkly.sdk.server.datasources.Synchronizer; import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; import com.launchdarkly.sdk.server.subsystems.ClientContext; @@ -85,13 +84,17 @@ public Synchronizer build(ClientContext context) { "Streaming", context.getBaseLogger()); - return new StreamingSynchronizerImpl( - context.getHttp(), - configuredBaseUri, - StandardEndpoints.FDV2_STREAMING_REQUEST_PATH, - context.getBaseLogger(), - context.getSelectorSource() - ); + // TODO: Implement FDv2StreamingSynchronizer + // return new StreamingSynchronizerImpl( + // context.getHttp(), + // configuredBaseUri, + // StandardEndpoints.FDV2_STREAMING_REQUEST_PATH, + // context.getBaseLogger(), + // context.getSelectorSource() + // ); + + // Placeholder - this will not compile until FDv2StreamingSynchronizer is fully integrated + throw new UnsupportedOperationException("FDv2StreamingSynchronizer is not yet implemented"); } @Override From b429ebaff6c9e496d148790f8f3ab35d2b6973d2 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Jan 2026 15:44:39 -0800 Subject: [PATCH 29/48] Basic streaming synchronizer. --- .../sdk/server/DefaultFDv2Requestor.java | 2 +- .../launchdarkly/sdk/server/PollingBase.java | 15 ++++- .../sdk/server/StreamingSynchronizerImpl.java | 65 +++++++++---------- .../server/StreamingSynchronizerImplTest.java | 23 ++++--- 4 files changed, 58 insertions(+), 47 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java index 290be0b3..0c031075 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java @@ -150,7 +150,7 @@ public void onResponse(@Nonnull Call call, @Nonnull Response response) { response.close(); } } - }); + }); } catch (Exception e) { future.completeExceptionally(e); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java index 5074bd12..28a55d2a 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java @@ -124,8 +124,21 @@ protected CompletableFuture poll(Selector selector, boolean on case NONE: break; case INTERNAL_ERROR: { + FDv2ProtocolHandler.FDv2ActionInternalError internalErrorAction = (FDv2ProtocolHandler.FDv2ActionInternalError) res; + DataSourceStatusProvider.ErrorKind kind = DataSourceStatusProvider.ErrorKind.UNKNOWN; + switch (internalErrorAction.getErrorType()) { + + case MISSING_PAYLOAD: + case JSON_ERROR: + kind = DataSourceStatusProvider.ErrorKind.INVALID_DATA; + break; + case UNKNOWN_EVENT: + case IMPLEMENTATION_ERROR: + case PROTOCOL_ERROR: + break; + } DataSourceStatusProvider.ErrorInfo info = new DataSourceStatusProvider.ErrorInfo( - DataSourceStatusProvider.ErrorKind.UNKNOWN, + kind, 0, "Internal error occurred during polling", new Date().toInstant()); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java index c4a02cb9..171aa53a 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java @@ -160,7 +160,7 @@ public CompletableFuture next() { } @Override - public void shutdown() { + public void close() { if (shutdownRequested.getAndSet(true)) { return; // already shutdown } @@ -198,17 +198,7 @@ private void handleMessage(MessageEvent event) { } catch (Exception e) { // Protocol handler threw exception processing the event - treat as invalid data logger.error("FDv2 protocol handler error: {}", LogValues.exceptionSummary(e)); - logger.debug(LogValues.exceptionTrace(e)); - DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( - DataSourceStatusProvider.ErrorKind.INVALID_DATA, - 0, - e.toString(), - Instant.now() - ); - resultQueue.put(FDv2SourceResult.interrupted(errorInfo)); - if (eventSource != null) { - eventSource.interrupt(); // restart the stream - } + interruptedWithException(e, DataSourceStatusProvider.ErrorKind.INVALID_DATA); return; } @@ -253,8 +243,21 @@ private void handleMessage(MessageEvent event) { break; case INTERNAL_ERROR: + FDv2ProtocolHandler.FDv2ActionInternalError internalErrorAction = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + DataSourceStatusProvider.ErrorKind kind = DataSourceStatusProvider.ErrorKind.UNKNOWN; + switch(internalErrorAction.getErrorType()) { + + case MISSING_PAYLOAD: + case JSON_ERROR: + kind = DataSourceStatusProvider.ErrorKind.INVALID_DATA; + break; + case UNKNOWN_EVENT: + case IMPLEMENTATION_ERROR: + case PROTOCOL_ERROR: + break; + } DataSourceStatusProvider.ErrorInfo internalError = new DataSourceStatusProvider.ErrorInfo( - DataSourceStatusProvider.ErrorKind.INVALID_DATA, + kind, 0, "Internal error during FDv2 event processing", Instant.now() @@ -281,34 +284,30 @@ private void handleMessage(MessageEvent event) { } } catch (SerializationException e) { logger.error("Failed to parse FDv2 event: {}", LogValues.exceptionSummary(e)); - logger.debug(LogValues.exceptionTrace(e)); - DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( - DataSourceStatusProvider.ErrorKind.INVALID_DATA, - 0, - e.toString(), - Instant.now() - ); - // Queue as INTERRUPTED, not TERMINAL_ERROR, so we can continue processing other events - resultQueue.put(FDv2SourceResult.interrupted(errorInfo)); - if (eventSource != null) { - eventSource.interrupt(); // restart the stream - } + interruptedWithException(e, DataSourceStatusProvider.ErrorKind.INVALID_DATA); } catch (Exception e) { logger.error("Unexpected error handling stream message: {}", LogValues.exceptionSummary(e)); - logger.debug(LogValues.exceptionTrace(e)); - DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( - DataSourceStatusProvider.ErrorKind.UNKNOWN, - 0, - e.toString(), - Instant.now() - ); - resultQueue.put(FDv2SourceResult.interrupted(errorInfo)); + interruptedWithException(e, DataSourceStatusProvider.ErrorKind.UNKNOWN); if (eventSource != null) { eventSource.interrupt(); // restart the stream } } } + private void interruptedWithException(Exception e, DataSourceStatusProvider.ErrorKind kind) { + logger.debug(LogValues.exceptionTrace(e)); + DataSourceStatusProvider.ErrorInfo errorInfo = new DataSourceStatusProvider.ErrorInfo( + kind, + 0, + e.toString(), + Instant.now() + ); + resultQueue.put(FDv2SourceResult.interrupted(errorInfo)); + if (eventSource != null) { + eventSource.interrupt(); // restart the stream + } + } + private boolean handleError(StreamException e) { if (e instanceof StreamClosedByCallerException) { // We closed it ourselves (shutdown was called) diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java index c5c22479..6897dae0 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java @@ -28,7 +28,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@SuppressWarnings("javadoc") public class StreamingSynchronizerImplTest extends BaseTest { private SelectorSource mockSelectorSource() { @@ -83,7 +82,7 @@ public void receivesMultipleChangesets() throws Exception { assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result2.getResultType()); assertNotNull(result2.getChangeSet()); - synchronizer.shutdown(); + synchronizer.close(); } } @@ -109,7 +108,7 @@ public void httpNonRecoverableError() throws Exception { assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, result.getStatus().getErrorInfo().getKind()); - synchronizer.shutdown(); + synchronizer.close(); } } @@ -135,7 +134,7 @@ public void httpRecoverableError() throws Exception { assertEquals(FDv2SourceResult.State.INTERRUPTED, result.getStatus().getState()); assertEquals(DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE, result.getStatus().getErrorInfo().getKind()); - synchronizer.shutdown(); + synchronizer.close(); } } @@ -161,7 +160,7 @@ public void networkError() throws Exception { assertEquals(FDv2SourceResult.State.INTERRUPTED, result.getStatus().getState()); assertEquals(DataSourceStatusProvider.ErrorKind.NETWORK_ERROR, result.getStatus().getErrorInfo().getKind()); - synchronizer.shutdown(); + synchronizer.close(); } @Test @@ -192,7 +191,7 @@ public void invalidEventData() throws Exception { assertEquals(FDv2SourceResult.State.INTERRUPTED, result.getStatus().getState()); assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, result.getStatus().getErrorInfo().getKind()); - synchronizer.shutdown(); + synchronizer.close(); } } @@ -217,7 +216,7 @@ public void shutdownBeforeEventReceived() throws Exception { // Wait a bit then shutdown Thread.sleep(100); - synchronizer.shutdown(); + synchronizer.close(); FDv2SourceResult result = nextFuture.get(5, TimeUnit.SECONDS); @@ -257,7 +256,7 @@ public void shutdownAfterEventReceived() throws Exception { assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); // Shutdown after receiving event should still work - synchronizer.shutdown(); + synchronizer.close(); } } @@ -288,7 +287,7 @@ public void goodbyeEventInResponse() throws Exception { assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); assertEquals(FDv2SourceResult.State.GOODBYE, result.getStatus().getState()); - synchronizer.shutdown(); + synchronizer.close(); } } @@ -324,7 +323,7 @@ public void heartbeatEvent() throws Exception { assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); assertNotNull(result.getChangeSet()); - synchronizer.shutdown(); + synchronizer.close(); } } @@ -367,7 +366,7 @@ public void selectorWithVersionAndState() throws Exception { assertThat(request.getQuery(), containsString("version=50")); assertThat(request.getQuery(), containsString("state=")); - synchronizer.shutdown(); + synchronizer.close(); } } @@ -436,7 +435,7 @@ public void selectorRefetchedOnReconnection() throws Exception { // Verify we made at least 2 requests assertTrue("Should have made at least 2 requests", server.getRecorder().count() >= 2); - synchronizer.shutdown(); + synchronizer.close(); } } } From 278f6709d96c506e883062d4f8132b1f1efc3184 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Jan 2026 15:52:41 -0800 Subject: [PATCH 30/48] Extend test coverage --- .../sdk/server/StreamingSynchronizerImpl.java | 10 +- .../server/StreamingSynchronizerImplTest.java | 177 ++++++++++++++++++ 2 files changed, 178 insertions(+), 9 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java index 171aa53a..c40f7dfa 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java @@ -192,15 +192,7 @@ private void handleMessage(MessageEvent event) { eventName = event.getEventName(); FDv2Event fdv2Event = parseFDv2Event(eventName, event.getDataReader()); - FDv2ProtocolHandler.IFDv2ProtocolAction action; - try { - action = protocolHandler.handleEvent(fdv2Event); - } catch (Exception e) { - // Protocol handler threw exception processing the event - treat as invalid data - logger.error("FDv2 protocol handler error: {}", LogValues.exceptionSummary(e)); - interruptedWithException(e, DataSourceStatusProvider.ErrorKind.INVALID_DATA); - return; - } + FDv2ProtocolHandler.IFDv2ProtocolAction action = protocolHandler.handleEvent(fdv2Event); FDv2SourceResult result = null; boolean shouldTerminate = false; diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java index 6897dae0..57e7c894 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java @@ -438,4 +438,181 @@ public void selectorRefetchedOnReconnection() throws Exception { synchronizer.close(); } } + + @Test + public void errorEventFromServer() throws Exception { + String errorEvent = makeEvent("error", "{\"id\":\"error-123\",\"reason\":\"some server error\"}"); + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(errorEvent), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen()))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + SelectorSource selectorSource = mockSelectorSource(); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource + ); + + // Error event should be logged but not queued, so we should get the changeset + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); + assertNotNull(result.getChangeSet()); + + synchronizer.close(); + } + } + + @Test + public void selectorWithVersionOnly() throws Exception { + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen()))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + + SelectorSource selectorSource = mock(SelectorSource.class); + when(selectorSource.getSelector()).thenReturn(Selector.make(75, null)); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource + ); + + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); + + // Verify the request had version but not state parameter + assertEquals(1, server.getRecorder().count()); + RequestInfo request = server.getRecorder().requireRequest(); + assertThat(request.getQuery(), containsString("version=75")); + // State should not be present (or if present, not have an actual state value) + + synchronizer.close(); + } + } + + @Test + public void selectorWithEmptyState() throws Exception { + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen()))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + + SelectorSource selectorSource = mock(SelectorSource.class); + when(selectorSource.getSelector()).thenReturn(Selector.make(80, "")); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource + ); + + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); + + // Verify the request had version but not state parameter (empty string shouldn't add state) + assertEquals(1, server.getRecorder().count()); + RequestInfo request = server.getRecorder().requireRequest(); + assertThat(request.getQuery(), containsString("version=80")); + + synchronizer.close(); + } + } + + @Test + public void closeCalledMultipleTimes() throws Exception { + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.hang()))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + SelectorSource selectorSource = mockSelectorSource(); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource + ); + + // Call close multiple times - should not throw exceptions + synchronizer.close(); + synchronizer.close(); + synchronizer.close(); + + // next() should still return shutdown + FDv2SourceResult result = synchronizer.next().get(1, TimeUnit.SECONDS); + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.SHUTDOWN, result.getStatus().getState()); + } + } + + @Test + public void invalidEventStructureCausesInterrupt() throws Exception { + // Event with missing required fields - should cause protocol handler to fail + String badEventStructure = makeEvent("put-object", "{}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(badEventStructure), + Handlers.SSE.leaveOpen()))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + SelectorSource selectorSource = mockSelectorSource(); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource + ); + + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.INTERRUPTED, result.getStatus().getState()); + + synchronizer.close(); + } + } } From 84be62d3aec4d60fc59711214d02a332b8b7a35a Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:30:45 -0800 Subject: [PATCH 31/48] Add payload filter and more testing. --- .../sdk/server/StreamingSynchronizerImpl.java | 285 ++++++++++-------- .../com/launchdarkly/sdk/server/Version.java | 2 + .../server/StreamingSynchronizerImplTest.java | 265 ++++++++++++++-- 3 files changed, 401 insertions(+), 151 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java index c40f7dfa..222e4539 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java @@ -25,11 +25,13 @@ import com.launchdarkly.sdk.server.subsystems.SerializationException; import com.google.gson.stream.JsonReader; import okhttp3.Headers; +import org.jetbrains.annotations.NotNull; import java.io.Reader; import java.net.URI; import java.time.Duration; import java.time.Instant; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -47,26 +49,33 @@ class StreamingSynchronizerImpl implements Synchronizer { private final SelectorSource selectorSource; final URI streamUri; private final LDLogger logger; + private final String payloadFilter; private final IterableAsyncQueue resultQueue = new IterableAsyncQueue<>(); private final CompletableFuture shutdownFuture = new CompletableFuture<>(); - private final AtomicBoolean shutdownRequested = new AtomicBoolean(false); + private final AtomicBoolean closed = new AtomicBoolean(false); private final FDv2ProtocolHandler protocolHandler = new FDv2ProtocolHandler(); private volatile EventSource eventSource; - private volatile Thread streamThread; + final Duration initialReconnectDelay; + + private final AtomicBoolean started = new AtomicBoolean(false); public StreamingSynchronizerImpl( HttpProperties httpProperties, URI baseUri, String requestPath, LDLogger logger, - SelectorSource selectorSource + SelectorSource selectorSource, + String payloadFilter, + Duration initialReconnectDelaySeconds ) { this.httpProperties = httpProperties; this.selectorSource = selectorSource; this.logger = logger; + this.payloadFilter = payloadFilter; this.streamUri = HttpHelpers.concatenateUriPath(baseUri, requestPath); + this.initialReconnectDelay = initialReconnectDelaySeconds; - startStream(); + // The stream will lazily start when `next` is called. } private void startStream() { @@ -78,20 +87,30 @@ private void startStream() { .headers(headers) .clientBuilderActions(clientBuilder -> { httpProperties.applyToHttpClientBuilder(clientBuilder); - // Add interceptor to inject selector query parameters on each request + // Add interceptor to inject selector and filter query parameters on each request clientBuilder.addInterceptor(chain -> { okhttp3.Request originalRequest = chain.request(); Selector selector = selectorSource.getSelector(); - if (selector.isEmpty()) { - return chain.proceed(originalRequest); + URI currentUri = originalRequest.url().uri(); + URI updatedUri = currentUri; + + // Add selector query parameters if the selector is not empty + if (!selector.isEmpty()) { + updatedUri = HttpHelpers.addQueryParam(updatedUri, "version", String.valueOf(selector.getVersion())); + if (selector.getState() != null && !selector.getState().isEmpty()) { + updatedUri = HttpHelpers.addQueryParam(updatedUri, "state", selector.getState()); + } } - // Build new URL with selector query parameters - URI currentUri = originalRequest.url().uri(); - URI updatedUri = HttpHelpers.addQueryParam(currentUri, "version", String.valueOf(selector.getVersion())); - if (selector.getState() != null && !selector.getState().isEmpty()) { - updatedUri = HttpHelpers.addQueryParam(updatedUri, "state", selector.getState()); + // Add the payloadFilter query parameter if present and non-empty + if (payloadFilter != null && !payloadFilter.isEmpty()) { + updatedUri = HttpHelpers.addQueryParam(updatedUri, "filter", payloadFilter); + } + + // If no parameters were added, proceed with the original request + if (updatedUri.equals(currentUri)) { + return chain.proceed(originalRequest); } okhttp3.Request newRequest = originalRequest.newBuilder() @@ -104,27 +123,37 @@ private void startStream() { EventSource.Builder builder = new EventSource.Builder(connectStrategy) .errorStrategy(ErrorStrategy.alwaysContinue()) + // alwaysContinue means we want EventSource to give us a FaultEvent rather + // than throwing an exception if the stream fails .logger(logger) .readBufferSize(5000) .streamEventData(true) - .expectFields("event"); + .expectFields("event") + .retryDelay(initialReconnectDelay.toMillis(), TimeUnit.MILLISECONDS); eventSource = builder.build(); - streamThread = new Thread(() -> { + Thread thread = getRunThread(); + thread.start(); + } + + @NotNull + private Thread getRunThread() { + Thread thread = new Thread(() -> { try { for (StreamEvent event : eventSource.anyEvents()) { - if (shutdownRequested.get()) { - break; - } - if (!handleEvent(event)) { break; } } } catch (Exception e) { - if (shutdownRequested.get()) { - return; + // Any uncaught runtime exception at this point would be coming from es.anyEvents(). + // That's not expected-- all deliberate EventSource exceptions are checked exceptions. + // So we have to assume something is wrong that we can't recover from at this point, + // and just let the thread terminate. That's better than having the thread be killed + // by an uncaught exception. + if (closed.get()) { + return; // ignore any exception that's just a side effect of stopping the EventSource } logger.error("Stream thread ended with exception: {}", LogValues.exceptionSummary(e)); logger.debug(LogValues.exceptionTrace(e)); @@ -135,38 +164,38 @@ private void startStream() { e.toString(), Instant.now() ); - resultQueue.put(FDv2SourceResult.terminalError(errorInfo)); + // We aren't restarting the event source here. We aren't going to automatically recover, so the + // data system will move to the next source when it determined this source is unhealthy. + resultQueue.put(FDv2SourceResult.interrupted(errorInfo)); } finally { - try { - if (eventSource != null) { - eventSource.close(); - } - } catch (Exception e) { - logger.debug("Error closing event source: {}", LogValues.exceptionSummary(e)); - } + eventSource.close(); } }); - streamThread.setName("LaunchDarkly-FDv2-streaming-synchronizer"); + thread.setName("LaunchDarkly-FDv2-streaming-synchronizer"); // TODO: Implement thread priority. //streamThread.setPriority(); - streamThread.setDaemon(true); - streamThread.start(); + thread.setDaemon(true); + return thread; } @Override public CompletableFuture next() { + if (!started.getAndSet(true)) { + startStream(); + } return CompletableFuture.anyOf(shutdownFuture, resultQueue.take()) .thenApply(result -> (FDv2SourceResult) result); } @Override public void close() { - if (shutdownRequested.getAndSet(true)) { + if (closed.getAndSet(true)) { return; // already shutdown } shutdownFuture.complete(FDv2SourceResult.shutdown()); + // If the synchronizer was never started, then the event source could be null. if (eventSource != null) { try { eventSource.close(); @@ -187,102 +216,96 @@ private boolean handleEvent(StreamEvent event) { } private void handleMessage(MessageEvent event) { - String eventName; + // Parse the event - this is the only place SerializationException can be thrown + String eventName = event.getEventName(); + FDv2Event fdv2Event; try { - eventName = event.getEventName(); - FDv2Event fdv2Event = parseFDv2Event(eventName, event.getDataReader()); - - FDv2ProtocolHandler.IFDv2ProtocolAction action = protocolHandler.handleEvent(fdv2Event); - - FDv2SourceResult result = null; - boolean shouldTerminate = false; - - switch (action.getAction()) { - case CHANGESET: - FDv2ProtocolHandler.FDv2ActionChangeset changeset = (FDv2ProtocolHandler.FDv2ActionChangeset) action; - try { - // TODO: Environment ID. - DataStoreTypes.ChangeSet converted = - FDv2ChangeSetTranslator.toChangeSet(changeset.getChangeset(), logger, null); - result = FDv2SourceResult.changeSet(converted); - } catch (Exception e) { - logger.error("Failed to convert FDv2 changeset: {}", LogValues.exceptionSummary(e)); - logger.debug(LogValues.exceptionTrace(e)); - DataSourceStatusProvider.ErrorInfo conversionError = new DataSourceStatusProvider.ErrorInfo( - DataSourceStatusProvider.ErrorKind.INVALID_DATA, - 0, - e.toString(), - Instant.now() - ); - result = FDv2SourceResult.interrupted(conversionError); - if (eventSource != null) { - eventSource.interrupt(); // restart the stream - } - } - break; - - case ERROR: - // In the case of an error, the protocol handler discards the result and we remain connected. - // We log the error to help with debugging. - FDv2ProtocolHandler.FDv2ActionError error = (FDv2ProtocolHandler.FDv2ActionError) action; - logger.error("Received error from server: {} - {}", error.getId(), error.getReason()); - break; - - case GOODBYE: - FDv2ProtocolHandler.FDv2ActionGoodbye goodbye = (FDv2ProtocolHandler.FDv2ActionGoodbye) action; - result = FDv2SourceResult.goodbye(goodbye.getReason()); - shouldTerminate = true; - break; - - case INTERNAL_ERROR: - FDv2ProtocolHandler.FDv2ActionInternalError internalErrorAction = (FDv2ProtocolHandler.FDv2ActionInternalError) action; - DataSourceStatusProvider.ErrorKind kind = DataSourceStatusProvider.ErrorKind.UNKNOWN; - switch(internalErrorAction.getErrorType()) { - - case MISSING_PAYLOAD: - case JSON_ERROR: - kind = DataSourceStatusProvider.ErrorKind.INVALID_DATA; - break; - case UNKNOWN_EVENT: - case IMPLEMENTATION_ERROR: - case PROTOCOL_ERROR: - break; - } - DataSourceStatusProvider.ErrorInfo internalError = new DataSourceStatusProvider.ErrorInfo( - kind, + fdv2Event = parseFDv2Event(eventName, event.getDataReader()); + } catch (SerializationException e) { + logger.error("Failed to parse FDv2 event: {}", LogValues.exceptionSummary(e)); + interruptedWithException(e, DataSourceStatusProvider.ErrorKind.INVALID_DATA); + return; + } + + // Handle the event with the protocol handler - this can throw exceptions on protocol errors + FDv2ProtocolHandler.IFDv2ProtocolAction action; + try { + action = protocolHandler.handleEvent(fdv2Event); + } catch (Exception e) { + // Protocol handler threw exception processing the event - treat as invalid data + logger.error("FDv2 protocol handler error: {}", LogValues.exceptionSummary(e)); + interruptedWithException(e, DataSourceStatusProvider.ErrorKind.INVALID_DATA); + return; + } + + FDv2SourceResult result = null; + switch (action.getAction()) { + case CHANGESET: + FDv2ProtocolHandler.FDv2ActionChangeset changeset = (FDv2ProtocolHandler.FDv2ActionChangeset) action; + try { + // TODO: Environment ID. + DataStoreTypes.ChangeSet converted = + FDv2ChangeSetTranslator.toChangeSet(changeset.getChangeset(), logger, null); + result = FDv2SourceResult.changeSet(converted); + } catch (Exception e) { + logger.error("Failed to convert FDv2 changeset: {}", LogValues.exceptionSummary(e)); + logger.debug(LogValues.exceptionTrace(e)); + DataSourceStatusProvider.ErrorInfo conversionError = new DataSourceStatusProvider.ErrorInfo( + DataSourceStatusProvider.ErrorKind.INVALID_DATA, 0, - "Internal error during FDv2 event processing", + e.toString(), Instant.now() ); - result = FDv2SourceResult.interrupted(internalError); - if (eventSource != null) { - eventSource.interrupt(); // restart the stream - } - break; - - case NONE: - // Continue processing events, don't queue anything - break; - } + result = FDv2SourceResult.interrupted(conversionError); + restartStream(); + } + break; + + case ERROR: + // In the case of an error, the protocol handler discards the result and we remain connected. + // We log the error to help with debugging. + FDv2ProtocolHandler.FDv2ActionError error = (FDv2ProtocolHandler.FDv2ActionError) action; + logger.error("Received error from server: {} - {}", error.getId(), error.getReason()); + break; + + case GOODBYE: + FDv2ProtocolHandler.FDv2ActionGoodbye goodbye = (FDv2ProtocolHandler.FDv2ActionGoodbye) action; + result = FDv2SourceResult.goodbye(goodbye.getReason()); + // We drop this current connection and attempt to restart the stream. + restartStream(); + break; + + case INTERNAL_ERROR: + FDv2ProtocolHandler.FDv2ActionInternalError internalErrorAction = (FDv2ProtocolHandler.FDv2ActionInternalError) action; + DataSourceStatusProvider.ErrorKind kind = DataSourceStatusProvider.ErrorKind.UNKNOWN; + switch (internalErrorAction.getErrorType()) { + + case MISSING_PAYLOAD: + case JSON_ERROR: + kind = DataSourceStatusProvider.ErrorKind.INVALID_DATA; + break; + case UNKNOWN_EVENT: + case IMPLEMENTATION_ERROR: + case PROTOCOL_ERROR: + break; + } + DataSourceStatusProvider.ErrorInfo internalError = new DataSourceStatusProvider.ErrorInfo( + kind, + 0, + "Internal error during FDv2 event processing", + Instant.now() + ); + result = FDv2SourceResult.interrupted(internalError); + restartStream(); + break; - if (result != null) { - resultQueue.put(result); - } + case NONE: + // Continue processing events, don't queue anything + break; + } - if (shouldTerminate) { - if (eventSource != null) { - eventSource.close(); - } - } - } catch (SerializationException e) { - logger.error("Failed to parse FDv2 event: {}", LogValues.exceptionSummary(e)); - interruptedWithException(e, DataSourceStatusProvider.ErrorKind.INVALID_DATA); - } catch (Exception e) { - logger.error("Unexpected error handling stream message: {}", LogValues.exceptionSummary(e)); - interruptedWithException(e, DataSourceStatusProvider.ErrorKind.UNKNOWN); - if (eventSource != null) { - eventSource.interrupt(); // restart the stream - } + if (result != null) { + resultQueue.put(result); } } @@ -295,9 +318,7 @@ private void interruptedWithException(Exception e, DataSourceStatusProvider.Erro Instant.now() ); resultQueue.put(FDv2SourceResult.interrupted(errorInfo)); - if (eventSource != null) { - eventSource.interrupt(); // restart the stream - } + restartStream(); } private boolean handleError(StreamException e) { @@ -339,10 +360,22 @@ private boolean handleError(StreamException e) { return true; // allow reconnect } + private void restartStream() { + Objects.requireNonNull(eventSource, "eventSource must not be null"); + eventSource.interrupt(); + protocolHandler.reset(); + } + private FDv2Event parseFDv2Event(String eventName, Reader eventDataReader) throws SerializationException { try { JsonReader reader = new JsonReader(eventDataReader); - return new FDv2Event(eventName, com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance().fromJson(reader, com.google.gson.JsonElement.class)); + FDv2Event event = new FDv2Event( + eventName, + com.launchdarkly.sdk.internal.GsonHelpers.gsonInstance().fromJson( + reader, + com.google.gson.JsonElement.class)); + reader.close(); + return event; } catch (Exception e) { throw new SerializationException(e); } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java index c92affab..85a5238f 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/Version.java @@ -4,5 +4,7 @@ abstract class Version { private Version() {} // This constant is updated automatically by our Gradle script during a release, if the project version has changed + // x-release-please-start-version static final String SDK_VERSION = "7.10.2"; + // x-release-please-end } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java index 57e7c894..b720a26b 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java @@ -11,6 +11,7 @@ import org.junit.Test; import java.net.URI; +import java.time.Duration; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -18,6 +19,7 @@ import static com.launchdarkly.sdk.server.TestComponents.clientContext; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -63,7 +65,9 @@ public void receivesMultipleChangesets() throws Exception { server.getUri(), "/stream", testLogger, - selectorSource + selectorSource, + null, + Duration.ofMillis(100) ); // First changeset @@ -97,7 +101,9 @@ public void httpNonRecoverableError() throws Exception { server.getUri(), "/stream", testLogger, - selectorSource + selectorSource, + null, + Duration.ofMillis(100) ); CompletableFuture resultFuture = synchronizer.next(); @@ -123,7 +129,9 @@ public void httpRecoverableError() throws Exception { server.getUri(), "/stream", testLogger, - selectorSource + selectorSource, + null, + Duration.ofMillis(100) ); CompletableFuture resultFuture = synchronizer.next(); @@ -149,7 +157,9 @@ public void networkError() throws Exception { URI.create("http://localhost:1"), // invalid port "/stream", testLogger, - selectorSource + selectorSource, + null, + Duration.ofMillis(100) ); CompletableFuture resultFuture = synchronizer.next(); @@ -180,7 +190,9 @@ public void invalidEventData() throws Exception { server.getUri(), "/stream", testLogger, - selectorSource + selectorSource, + null, + Duration.ofMillis(100) ); CompletableFuture resultFuture = synchronizer.next(); @@ -209,7 +221,9 @@ public void shutdownBeforeEventReceived() throws Exception { server.getUri(), "/stream", testLogger, - selectorSource + selectorSource, + null, + Duration.ofMillis(100) ); CompletableFuture nextFuture = synchronizer.next(); @@ -246,7 +260,9 @@ public void shutdownAfterEventReceived() throws Exception { server.getUri(), "/stream", testLogger, - selectorSource + selectorSource, + null, + Duration.ofMillis(100) ); CompletableFuture resultFuture = synchronizer.next(); @@ -263,11 +279,20 @@ public void shutdownAfterEventReceived() throws Exception { @Test public void goodbyeEventInResponse() throws Exception { String goodbyeEvent = makeEvent("goodbye", "{\"reason\":\"service-unavailable\"}"); + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); - try (HttpServer server = HttpServer.start(Handlers.all( - Handlers.SSE.start(), - Handlers.SSE.event(goodbyeEvent), - Handlers.SSE.leaveOpen()))) { + // First connection: send goodbye, then second connection: send changeset to verify restart + try (HttpServer server = HttpServer.start(Handlers.sequential( + Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(goodbyeEvent), + Handlers.SSE.leaveOpen()), + Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen())))) { HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); SelectorSource selectorSource = mockSelectorSource(); @@ -277,15 +302,29 @@ public void goodbyeEventInResponse() throws Exception { server.getUri(), "/stream", testLogger, - selectorSource + selectorSource, + null, + Duration.ofMillis(100) ); - CompletableFuture resultFuture = synchronizer.next(); - FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + // First result should be goodbye + CompletableFuture result1Future = synchronizer.next(); + FDv2SourceResult result1 = result1Future.get(5, TimeUnit.SECONDS); - assertNotNull(result); - assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); - assertEquals(FDv2SourceResult.State.GOODBYE, result.getStatus().getState()); + assertNotNull(result1); + assertEquals(FDv2SourceResult.ResultType.STATUS, result1.getResultType()); + assertEquals(FDv2SourceResult.State.GOODBYE, result1.getStatus().getState()); + + // Second result should be a changeset from the restarted stream + CompletableFuture result2Future = synchronizer.next(); + FDv2SourceResult result2 = result2Future.get(5, TimeUnit.SECONDS); + + assertNotNull(result2); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result2.getResultType()); + assertNotNull(result2.getChangeSet()); + + // Verify we made 2 requests (initial connection + reconnection after goodbye) + assertTrue("Should have made at least 2 requests", server.getRecorder().count() >= 2); synchronizer.close(); } @@ -312,7 +351,9 @@ public void heartbeatEvent() throws Exception { server.getUri(), "/stream", testLogger, - selectorSource + selectorSource, + null, + Duration.ofMillis(100) ); CompletableFuture resultFuture = synchronizer.next(); @@ -348,7 +389,9 @@ public void selectorWithVersionAndState() throws Exception { server.getUri(), "/stream", testLogger, - selectorSource + selectorSource, + null, + Duration.ofMillis(100) ); CompletableFuture resultFuture = synchronizer.next(); @@ -402,7 +445,9 @@ public void selectorRefetchedOnReconnection() throws Exception { server.getUri(), "/stream", testLogger, - selectorSource + selectorSource, + null, + Duration.ofMillis(100) ); // First result should be an error from the 503 @@ -460,7 +505,9 @@ public void errorEventFromServer() throws Exception { server.getUri(), "/stream", testLogger, - selectorSource + selectorSource, + null, + Duration.ofMillis(100) ); // Error event should be logged but not queued, so we should get the changeset @@ -496,7 +543,9 @@ public void selectorWithVersionOnly() throws Exception { server.getUri(), "/stream", testLogger, - selectorSource + selectorSource, + null, + Duration.ofMillis(100) ); CompletableFuture resultFuture = synchronizer.next(); @@ -536,7 +585,9 @@ public void selectorWithEmptyState() throws Exception { server.getUri(), "/stream", testLogger, - selectorSource + selectorSource, + null, + Duration.ofMillis(100) ); CompletableFuture resultFuture = synchronizer.next(); @@ -568,7 +619,9 @@ public void closeCalledMultipleTimes() throws Exception { server.getUri(), "/stream", testLogger, - selectorSource + selectorSource, + null, + Duration.ofMillis(100) ); // Call close multiple times - should not throw exceptions @@ -602,7 +655,9 @@ public void invalidEventStructureCausesInterrupt() throws Exception { server.getUri(), "/stream", testLogger, - selectorSource + selectorSource, + null, + Duration.ofMillis(100) ); CompletableFuture resultFuture = synchronizer.next(); @@ -615,4 +670,164 @@ public void invalidEventStructureCausesInterrupt() throws Exception { synchronizer.close(); } } + + @Test + public void payloadFilterIsAddedToRequest() throws Exception { + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen()))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + SelectorSource selectorSource = mockSelectorSource(); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource, + "myFilter", + Duration.ofMillis(100) + ); + + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); + + // Verify the request had the filter parameter + assertEquals(1, server.getRecorder().count()); + RequestInfo request = server.getRecorder().requireRequest(); + assertThat(request.getQuery(), containsString("filter=myFilter")); + + synchronizer.close(); + } + } + + @Test + public void payloadFilterWithSelectorBothAddedToRequest() throws Exception { + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen()))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + + SelectorSource selectorSource = mock(SelectorSource.class); + when(selectorSource.getSelector()).thenReturn(Selector.make(42, "(p:test:42)")); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource, + "testFilter", + Duration.ofMillis(100) + ); + + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); + + // Verify the request had both filter and selector parameters + assertEquals(1, server.getRecorder().count()); + RequestInfo request = server.getRecorder().requireRequest(); + assertThat(request.getQuery(), containsString("filter=testFilter")); + assertThat(request.getQuery(), containsString("version=42")); + assertThat(request.getQuery(), containsString("state=")); + + synchronizer.close(); + } + } + + @Test + public void emptyPayloadFilterNotAddedToRequest() throws Exception { + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen()))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + SelectorSource selectorSource = mockSelectorSource(); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource, + "", + Duration.ofMillis(100) + ); + + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); + + // Verify the request did not have the filter parameter + assertEquals(1, server.getRecorder().count()); + RequestInfo request = server.getRecorder().requireRequest(); + assertThat(request.getQuery(), not(containsString("filter"))); + + synchronizer.close(); + } + } + + @Test + public void nullPayloadFilterNotAddedToRequest() throws Exception { + String serverIntent = makeEvent("server-intent", "{\"payloads\":[{\"id\":\"payload-1\",\"target\":100,\"intentCode\":\"xfer-full\",\"reason\":\"payload-missing\"}]}"); + String payloadTransferred = makeEvent("payload-transferred", "{\"state\":\"(p:payload-1:100)\",\"version\":100}"); + + try (HttpServer server = HttpServer.start(Handlers.all( + Handlers.SSE.start(), + Handlers.SSE.event(serverIntent), + Handlers.SSE.event(payloadTransferred), + Handlers.SSE.leaveOpen()))) { + + HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); + SelectorSource selectorSource = mockSelectorSource(); + + StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( + httpProperties, + server.getUri(), + "/stream", + testLogger, + selectorSource, + null, + Duration.ofMillis(100) + ); + + CompletableFuture resultFuture = synchronizer.next(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); + + // Verify the request did not have the filter parameter + assertEquals(1, server.getRecorder().count()); + RequestInfo request = server.getRecorder().requireRequest(); + assertThat(request.getQuery(), not(containsString("filter"))); + + synchronizer.close(); + } + } } From ec609a58ea5d183147a0c0f77765240a6ce99a2b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:32:55 -0800 Subject: [PATCH 32/48] Add comments to FDv2 data source interfaces. --- .../com/launchdarkly/sdk/server/datasources/Initializer.java | 2 ++ .../com/launchdarkly/sdk/server/datasources/Synchronizer.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java index af22ebce..c07d2635 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Initializer.java @@ -46,6 +46,8 @@ public interface Initializer extends Closeable { /** * Run the initializer to completion. + *

+ * This method is intended to be called only single time for an instance. * @return The result of the initializer. */ CompletableFuture run(); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java index 0eeed89a..6e4a8c54 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java @@ -52,6 +52,8 @@ public interface Synchronizer extends Closeable { *

* This method is intended to be driven by a single thread, and for there to be a single outstanding call * at any given time. + *

+ * Once SHUTDOWN, TERMINAL_ERROR, or GOODBYE has been produced, then no further calls to next() should be made. * @return a future that will complete when the next result is available */ CompletableFuture next(); From 3461149338a8ffad5025873c4a32497c5cfbc0e6 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:34:46 -0800 Subject: [PATCH 33/48] Remove extra blank lines --- .../src/main/java/com/launchdarkly/sdk/server/PollingBase.java | 1 - .../com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java | 1 - 2 files changed, 2 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java index 28a55d2a..20a5a1f1 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingBase.java @@ -127,7 +127,6 @@ protected CompletableFuture poll(Selector selector, boolean on FDv2ProtocolHandler.FDv2ActionInternalError internalErrorAction = (FDv2ProtocolHandler.FDv2ActionInternalError) res; DataSourceStatusProvider.ErrorKind kind = DataSourceStatusProvider.ErrorKind.UNKNOWN; switch (internalErrorAction.getErrorType()) { - case MISSING_PAYLOAD: case JSON_ERROR: kind = DataSourceStatusProvider.ErrorKind.INVALID_DATA; diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java index 222e4539..899483cf 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java @@ -279,7 +279,6 @@ private void handleMessage(MessageEvent event) { FDv2ProtocolHandler.FDv2ActionInternalError internalErrorAction = (FDv2ProtocolHandler.FDv2ActionInternalError) action; DataSourceStatusProvider.ErrorKind kind = DataSourceStatusProvider.ErrorKind.UNKNOWN; switch (internalErrorAction.getErrorType()) { - case MISSING_PAYLOAD: case JSON_ERROR: kind = DataSourceStatusProvider.ErrorKind.INVALID_DATA; From b32d3ca05f3223abd3b242c967ac899217bbbb6f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:38:43 -0800 Subject: [PATCH 34/48] Revert requestor change --- .../java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java index 0c031075..290be0b3 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java @@ -150,7 +150,7 @@ public void onResponse(@Nonnull Call call, @Nonnull Response response) { response.close(); } } - }); + }); } catch (Exception e) { future.completeExceptionally(e); From 97ac7c45f01bba050f32e88ad63b761a501752a8 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:40:27 -0800 Subject: [PATCH 35/48] Remove leftover file. --- .../sdk/server/datasources/DataSourceShutdown.java | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/DataSourceShutdown.java diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/DataSourceShutdown.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/DataSourceShutdown.java deleted file mode 100644 index 63829b12..00000000 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/DataSourceShutdown.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.launchdarkly.sdk.server.datasources; - -/** - * This type is currently experimental and not subject to semantic versioning. - *

- * Interface used to shut down a data source. - */ -public interface DataSourceShutdown { - /** - * Shutdown the data source. The data source should emit a status event with a SHUTDOWN state as soon as possible. - * If the data source has already completed, or is in the process of completing, this method should have no effect. - */ - void shutdown(); -} From 7c84a6815cec1ee43a47020ffac3f70ffbc20804 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:52:46 -0800 Subject: [PATCH 36/48] Extend polling tests for INTERNAL_ERROR --- .../server/PollingInitializerImplTest.java | 94 +++++++++++- .../server/PollingSynchronizerImplTest.java | 144 ++++++++++++++++++ 2 files changed, 237 insertions(+), 1 deletion(-) diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java index f0113c61..608b04bc 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java @@ -329,6 +329,98 @@ public void emptyEventsArray() throws Exception { assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); - + + } + + @Test + public void internalErrorWithInvalidDataKind() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + // Create a response with malformed payload-transferred event. `state->states`. + // This will trigger JSON_ERROR internal error which maps to INVALID_DATA + String malformedPutObjectJson = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"server-intent\",\n" + + " \"data\": {\n" + + " \"payloads\": [{\n" + + " \"id\": \"payload-1\",\n" + + " \"target\": 100,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }]\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"payload-transferred\",\n" + + " \"data\": {\n" + + " \"states\": \"(p:payload-1:100)\",\n" + + " \"version\": 100\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"put-object\",\n" + + " \"data\": {}\n" + + " }\n" + + " ]\n" + + "}"; + + FDv2Requestor.FDv2PayloadResponse response = new FDv2Requestor.FDv2PayloadResponse( + com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(malformedPutObjectJson), + okhttp3.Headers.of() + ); + + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, result.getStatus().getErrorInfo().getKind()); + + + } + + @Test + public void internalErrorWithUnknownKind() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + + // Create a response with an unrecognized event type + // This will trigger UNKNOWN_EVENT internal error which maps to UNKNOWN error kind + String unknownEventJson = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"unrecognized-event-type\",\n" + + " \"data\": {}\n" + + " }\n" + + " ]\n" + + "}"; + + FDv2Requestor.FDv2PayloadResponse response = new FDv2Requestor.FDv2PayloadResponse( + com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(unknownEventJson), + okhttp3.Headers.of() + ); + + when(requestor.Poll(any(Selector.class))) + .thenReturn(CompletableFuture.completedFuture(response)); + + PollingInitializerImpl initializer = new PollingInitializerImpl(requestor, testLogger, selectorSource); + + CompletableFuture resultFuture = initializer.run(); + FDv2SourceResult result = resultFuture.get(5, TimeUnit.SECONDS); + + assertNotNull(result); + assertEquals(FDv2SourceResult.ResultType.STATUS, result.getResultType()); + assertEquals(FDv2SourceResult.State.TERMINAL_ERROR, result.getStatus().getState()); + assertEquals(DataSourceStatusProvider.ErrorKind.UNKNOWN, result.getStatus().getErrorInfo().getKind()); + + } } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java index dc3e1f8b..312a9d8e 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java @@ -722,4 +722,148 @@ public void nonRecoverableThenRecoverableErrorStopsPolling() throws Exception { executor.shutdown(); } } + + @Test + public void internalErrorWithInvalidDataKindContinuesPolling() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + + AtomicInteger callCount = new AtomicInteger(0); + when(requestor.Poll(any(Selector.class))).thenAnswer(invocation -> { + int count = callCount.incrementAndGet(); + if (count == 1) { + // First call returns response with malformed put-object which triggers INTERNAL_ERROR (INVALID_DATA) + String malformedPutObjectJson = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"server-intent\",\n" + + " \"data\": {\n" + + " \"payloads\": [{\n" + + " \"id\": \"payload-1\",\n" + + " \"target\": 100,\n" + + " \"intentCode\": \"xfer-full\",\n" + + " \"reason\": \"payload-missing\"\n" + + " }]\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"payload-transferred\",\n" + + " \"data\": {\n" + + " \"state\": \"(p:payload-1:100)\",\n" + + " \"version\": 100\n" + + " }\n" + + " },\n" + + " {\n" + + " \"event\": \"put-object\",\n" + + " \"data\": {}\n" + + " }\n" + + " ]\n" + + "}"; + + return CompletableFuture.completedFuture(new FDv2Requestor.FDv2PayloadResponse( + com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(malformedPutObjectJson), + okhttp3.Headers.of() + )); + } else { + // Subsequent calls succeed + return CompletableFuture.completedFuture(makeSuccessResponse()); + } + }); + + try { + PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(50) + ); + + // Wait for multiple polls + Thread.sleep(250); + + // First result should be interrupted with INVALID_DATA error kind + FDv2SourceResult result1 = synchronizer.next().get(1, TimeUnit.SECONDS); + assertNotNull(result1); + assertEquals(FDv2SourceResult.ResultType.STATUS, result1.getResultType()); + assertEquals(FDv2SourceResult.State.INTERRUPTED, result1.getStatus().getState()); + assertEquals(DataSourceStatusProvider.ErrorKind.INVALID_DATA, result1.getStatus().getErrorInfo().getKind()); + + // Second result should be success (polling continued) + FDv2SourceResult result2 = synchronizer.next().get(1, TimeUnit.SECONDS); + assertNotNull(result2); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result2.getResultType()); + + // Verify polling continued after internal error + assertTrue("Should have made at least 2 calls", callCount.get() >= 2); + + synchronizer.close(); + } finally { + executor.shutdown(); + } + } + + @Test + public void internalErrorWithUnknownKindContinuesPolling() throws Exception { + FDv2Requestor requestor = mockRequestor(); + SelectorSource selectorSource = mockSelectorSource(); + ScheduledExecutorService executor = Executors.newScheduledThreadPool(1); + + AtomicInteger callCount = new AtomicInteger(0); + when(requestor.Poll(any(Selector.class))).thenAnswer(invocation -> { + int count = callCount.incrementAndGet(); + if (count == 1) { + // First call returns response with unknown event which triggers INTERNAL_ERROR (UNKNOWN) + String unknownEventJson = "{\n" + + " \"events\": [\n" + + " {\n" + + " \"event\": \"unrecognized-event-type\",\n" + + " \"data\": {}\n" + + " }\n" + + " ]\n" + + "}"; + + return CompletableFuture.completedFuture(new FDv2Requestor.FDv2PayloadResponse( + com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(unknownEventJson), + okhttp3.Headers.of() + )); + } else { + // Subsequent calls succeed + return CompletableFuture.completedFuture(makeSuccessResponse()); + } + }); + + try { + PollingSynchronizerImpl synchronizer = new PollingSynchronizerImpl( + requestor, + testLogger, + selectorSource, + executor, + Duration.ofMillis(50) + ); + + // Wait for multiple polls + Thread.sleep(250); + + // First result should be interrupted with UNKNOWN error kind + FDv2SourceResult result1 = synchronizer.next().get(1, TimeUnit.SECONDS); + assertNotNull(result1); + assertEquals(FDv2SourceResult.ResultType.STATUS, result1.getResultType()); + assertEquals(FDv2SourceResult.State.INTERRUPTED, result1.getStatus().getState()); + assertEquals(DataSourceStatusProvider.ErrorKind.UNKNOWN, result1.getStatus().getErrorInfo().getKind()); + + // Second result should be success (polling continued) + FDv2SourceResult result2 = synchronizer.next().get(1, TimeUnit.SECONDS); + assertNotNull(result2); + assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result2.getResultType()); + + // Verify polling continued after internal error + assertTrue("Should have made at least 2 calls", callCount.get() >= 2); + + synchronizer.close(); + } finally { + executor.shutdown(); + } + } } From 9c43cbbf0e6c6b63aa0d5eff1b406500266cbe8d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:56:49 -0800 Subject: [PATCH 37/48] Handle close before start. --- .../com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java index 899483cf..15de0595 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java @@ -180,7 +180,8 @@ private Thread getRunThread() { @Override public CompletableFuture next() { - if (!started.getAndSet(true)) { + // If we are already closed, don't start the stream. + if (!started.getAndSet(true) && !closed.get()) { startStream(); } return CompletableFuture.anyOf(shutdownFuture, resultQueue.take()) From a383fc935e46c190941227b4cd35f7ce1d39d412 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:18:20 -0800 Subject: [PATCH 38/48] Threading and tests. --- .../sdk/server/PollingSynchronizerImpl.java | 12 ++++-- .../sdk/server/StreamingSynchronizerImpl.java | 42 +++++++++++-------- .../server/PollingInitializerImplTest.java | 4 +- .../server/PollingSynchronizerImplTest.java | 8 ++-- 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java index ccb3d189..8bbc6a4e 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/PollingSynchronizerImpl.java @@ -38,11 +38,12 @@ public PollingSynchronizerImpl( private void doPoll() { try { FDv2SourceResult res = poll(selectorSource.getSelector(), false).get(); - switch(res.getResultType()) { + boolean shouldShutdown = false; + switch (res.getResultType()) { case CHANGE_SET: break; case STATUS: - switch(res.getStatus().getState()) { + switch (res.getStatus().getState()) { case INTERRUPTED: break; case SHUTDOWN: @@ -54,6 +55,7 @@ private void doPoll() { task.cancel(true); } internalShutdown(); + shouldShutdown = true; break; case GOODBYE: // We don't need to take any action, as the connection for the poll @@ -63,7 +65,11 @@ private void doPoll() { } break; } - resultQueue.put(res); + if (shouldShutdown) { + shutdownFuture.complete(res); + } else { + resultQueue.put(res); + } } catch (InterruptedException | ExecutionException e) { // TODO: Determine if handling is needed. } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java index 15de0595..7d5b5580 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java @@ -52,7 +52,8 @@ class StreamingSynchronizerImpl implements Synchronizer { private final String payloadFilter; private final IterableAsyncQueue resultQueue = new IterableAsyncQueue<>(); private final CompletableFuture shutdownFuture = new CompletableFuture<>(); - private final AtomicBoolean closed = new AtomicBoolean(false); + private boolean closed = false; + private final Object closeLock = new Object(); private final FDv2ProtocolHandler protocolHandler = new FDv2ProtocolHandler(); private volatile EventSource eventSource; final Duration initialReconnectDelay; @@ -152,8 +153,10 @@ private Thread getRunThread() { // So we have to assume something is wrong that we can't recover from at this point, // and just let the thread terminate. That's better than having the thread be killed // by an uncaught exception. - if (closed.get()) { - return; // ignore any exception that's just a side effect of stopping the EventSource + synchronized (closeLock) { + if (closed) { + return; + } } logger.error("Stream thread ended with exception: {}", LogValues.exceptionSummary(e)); logger.debug(LogValues.exceptionTrace(e)); @@ -181,29 +184,34 @@ private Thread getRunThread() { @Override public CompletableFuture next() { // If we are already closed, don't start the stream. - if (!started.getAndSet(true) && !closed.get()) { - startStream(); + synchronized (closeLock) { + if (!closed) { + if (!started.getAndSet(true)) { + startStream(); + } + } } + return CompletableFuture.anyOf(shutdownFuture, resultQueue.take()) .thenApply(result -> (FDv2SourceResult) result); } @Override public void close() { - if (closed.getAndSet(true)) { - return; // already shutdown - } - - shutdownFuture.complete(FDv2SourceResult.shutdown()); + synchronized (closeLock){ + closed = true; - // If the synchronizer was never started, then the event source could be null. - if (eventSource != null) { - try { - eventSource.close(); - } catch (Exception e) { - logger.debug("Error closing event source during shutdown: {}", LogValues.exceptionSummary(e)); + // If the synchronizer was never started, then the event source could be null. + if (eventSource != null) { + try { + eventSource.close(); + } catch (Exception e) { + logger.debug("Error closing event source during shutdown: {}", LogValues.exceptionSummary(e)); + } } } + + shutdownFuture.complete(FDv2SourceResult.shutdown()); } private boolean handleEvent(StreamEvent event) { @@ -338,7 +346,7 @@ private boolean handleError(StreamException e) { "will retry"); if (!recoverable) { - resultQueue.put(FDv2SourceResult.terminalError(errorInfo)); + shutdownFuture.complete(FDv2SourceResult.terminalError(errorInfo)); return false; } else { // Queue as INTERRUPTED to indicate temporary failure diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java index 608b04bc..c97194f7 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingInitializerImplTest.java @@ -339,7 +339,7 @@ public void internalErrorWithInvalidDataKind() throws Exception { // Create a response with malformed payload-transferred event. `state->states`. // This will trigger JSON_ERROR internal error which maps to INVALID_DATA - String malformedPutObjectJson = "{\n" + + String malformedPayloadTransferred = "{\n" + " \"events\": [\n" + " {\n" + " \"event\": \"server-intent\",\n" + @@ -367,7 +367,7 @@ public void internalErrorWithInvalidDataKind() throws Exception { "}"; FDv2Requestor.FDv2PayloadResponse response = new FDv2Requestor.FDv2PayloadResponse( - com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(malformedPutObjectJson), + com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(malformedPayloadTransferred), okhttp3.Headers.of() ); diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java index 312a9d8e..4dbccd02 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/PollingSynchronizerImplTest.java @@ -733,8 +733,8 @@ public void internalErrorWithInvalidDataKindContinuesPolling() throws Exception when(requestor.Poll(any(Selector.class))).thenAnswer(invocation -> { int count = callCount.incrementAndGet(); if (count == 1) { - // First call returns response with malformed put-object which triggers INTERNAL_ERROR (INVALID_DATA) - String malformedPutObjectJson = "{\n" + + // First call returns response with malformed payload transfer. state->states + String malformedPayloadTransferred = "{\n" + " \"events\": [\n" + " {\n" + " \"event\": \"server-intent\",\n" + @@ -750,7 +750,7 @@ public void internalErrorWithInvalidDataKindContinuesPolling() throws Exception " {\n" + " \"event\": \"payload-transferred\",\n" + " \"data\": {\n" + - " \"state\": \"(p:payload-1:100)\",\n" + + " \"states\": \"(p:payload-1:100)\",\n" + " \"version\": 100\n" + " }\n" + " },\n" + @@ -762,7 +762,7 @@ public void internalErrorWithInvalidDataKindContinuesPolling() throws Exception "}"; return CompletableFuture.completedFuture(new FDv2Requestor.FDv2PayloadResponse( - com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(malformedPutObjectJson), + com.launchdarkly.sdk.internal.fdv2.payloads.FDv2Event.parseEventsArray(malformedPayloadTransferred), okhttp3.Headers.of() )); } else { From 9c1c4c4ed32e11fe04ae9005e23db87c4cdfe90d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:21:23 -0800 Subject: [PATCH 39/48] Update documentation. --- .../sdk/server/datasources/Synchronizer.java | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java index 6e4a8c54..298a0bff 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/datasources/Synchronizer.java @@ -11,7 +11,7 @@ // RUNNING --> TERMINAL_ERROR // TERMINAL_ERROR --> [*] // RUNNING --> GOODBYE -// GOODBYE --> [*] +// GOODBYE --> RUNNING // RUNNING --> CHANGE_SET // CHANGE_SET --> RUNNING // RUNNING --> INTERRUPTED @@ -34,17 +34,17 @@ * ┌─►│ RUNNING │──┐ * │ └─────────────┘ │ * │ │ │ │ │ │ - * │ │ │ │ │ └──► SHUTDOWN ───► [END] + * │ │ │ │ │ └──► SHUTDOWN ───────────► [END] * │ │ │ │ │ - * │ │ │ │ └──────► TERMINAL_ERROR ───► [END] + * │ │ │ │ └──────► TERMINAL_ERROR ─────► [END] * │ │ │ │ - * │ │ │ └──────────► GOODBYE ───► [END] - * │ │ │ - * │ │ └──────────────► CHANGE_SET ───┐ - * │ │ │ - * │ └──────────────────► INTERRUPTED ──┤ - * │ │ - * └──────────────────────────────────────┘ + * │ │ │ └──────────► GOODBYE ────────────┐ + * │ │ │ │ + * │ │ └──────────────► CHANGE_SET ─────────┤ + * │ │ │ + * │ └──────────────────► INTERRUPTED ────────┤ + * │ │ + * └────────────────────────────────────────────┘ */ public interface Synchronizer extends Closeable { /** @@ -53,7 +53,7 @@ public interface Synchronizer extends Closeable { * This method is intended to be driven by a single thread, and for there to be a single outstanding call * at any given time. *

- * Once SHUTDOWN, TERMINAL_ERROR, or GOODBYE has been produced, then no further calls to next() should be made. + * Once SHUTDOWN or TERMINAL_ERROR, has been produced, then no further calls to next() should be made. * @return a future that will complete when the next result is available */ CompletableFuture next(); From 7bb639439264b18dcbd977a8d4ae53e771f67765 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:40:35 -0800 Subject: [PATCH 40/48] chore: Connect FDv2 Configuration. --- .../sdk/server/DataSystemComponents.java | 145 ++++++++++++++++++ .../sdk/server/DefaultFDv2Requestor.java | 9 +- .../sdk/server/FDv2DataSource.java | 80 +++++----- .../sdk/server/FDv2DataSystem.java | 132 +++++++++++++--- .../sdk/server/SelectorSourceFacade.java | 19 +++ .../sdk/server/StreamingSynchronizerImpl.java | 5 +- .../integrations/DataSystemBuilder.java | 37 ++--- .../integrations/DataSystemComponents.java | 55 ------- .../server/integrations/DataSystemModes.java | 1 + .../FDv2PollingInitializerBuilder.java | 84 ++-------- .../FDv2PollingSynchronizerBuilder.java | 34 +--- .../FDv2StreamingSynchronizerBuilder.java | 31 +--- .../subsystems/DataSystemConfiguration.java | 20 +-- .../server/subsystems/InitializerBuilder.java | 8 + .../subsystems/SynchronizerBuilder.java | 8 + .../sdk/server/ConfigurationTest.java | 7 +- 16 files changed, 383 insertions(+), 292 deletions(-) create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystemComponents.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/SelectorSourceFacade.java delete mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemComponents.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/InitializerBuilder.java create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/SynchronizerBuilder.java diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystemComponents.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystemComponents.java new file mode 100644 index 00000000..cb0f40cb --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystemComponents.java @@ -0,0 +1,145 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.datasources.Initializer; +import com.launchdarkly.sdk.server.datasources.SelectorSource; +import com.launchdarkly.sdk.server.datasources.Synchronizer; +import com.launchdarkly.sdk.server.integrations.FDv2PollingInitializerBuilder; +import com.launchdarkly.sdk.server.integrations.FDv2PollingSynchronizerBuilder; +import com.launchdarkly.sdk.server.integrations.FDv2StreamingSynchronizerBuilder; +import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; +import com.launchdarkly.sdk.server.subsystems.ClientContext; + +import java.net.URI; + +import static com.launchdarkly.sdk.server.ComponentsImpl.toHttpProperties; + +/** + * Components for use with the data system. + *

+ * This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. + * It is in early access. If you want access to this feature please join the EAP. https://launchdarkly.com/docs/sdk/features/data-saving-mode + *

+ */ +public final class DataSystemComponents { + + static class FDv2PollingInitializerBuilderImpl extends FDv2PollingInitializerBuilder { + @Override + public Initializer build(ClientContext context, SelectorSource selectorSource) { + ServiceEndpoints endpoints = serviceEndpointsOverride != null + ? serviceEndpointsOverride + : context.getServiceEndpoints(); + URI configuredBaseUri = StandardEndpoints.selectBaseUri( + endpoints.getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "Polling", + context.getBaseLogger()); + + DefaultFDv2Requestor requestor = new DefaultFDv2Requestor( + toHttpProperties(context.getHttp()), + configuredBaseUri, + StandardEndpoints.FDV2_POLLING_REQUEST_PATH, + context.getBaseLogger()); + + return new PollingInitializerImpl( + requestor, + context.getBaseLogger(), + selectorSource + ); + } + } + + static class FDv2PollingSynchronizerBuilderImpl extends FDv2PollingSynchronizerBuilder { + @Override + public Synchronizer build(ClientContext context, SelectorSource selectorSource) { + ServiceEndpoints endpoints = serviceEndpointsOverride != null + ? serviceEndpointsOverride + : context.getServiceEndpoints(); + URI configuredBaseUri = StandardEndpoints.selectBaseUri( + endpoints.getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "Polling", + context.getBaseLogger()); + + DefaultFDv2Requestor requestor = new DefaultFDv2Requestor( + toHttpProperties(context.getHttp()), + configuredBaseUri, + StandardEndpoints.FDV2_POLLING_REQUEST_PATH, + context.getBaseLogger()); + + return new PollingSynchronizerImpl( + requestor, + context.getBaseLogger(), + selectorSource, + ClientContextImpl.get(context).sharedExecutor, + pollInterval + ); + } + } + + static class FDv2StreamingSynchronizerBuilderImpl extends FDv2StreamingSynchronizerBuilder { + @Override + public Synchronizer build(ClientContext context, SelectorSource selectorSource) { + ServiceEndpoints endpoints = serviceEndpointsOverride != null + ? serviceEndpointsOverride + : context.getServiceEndpoints(); + URI configuredBaseUri = StandardEndpoints.selectBaseUri( + endpoints.getStreamingBaseUri(), + StandardEndpoints.DEFAULT_STREAMING_BASE_URI, + "Streaming", + context.getBaseLogger()); + + return new StreamingSynchronizerImpl( + toHttpProperties(context.getHttp()), + configuredBaseUri, + StandardEndpoints.FDV2_STREAMING_REQUEST_PATH, + context.getBaseLogger(), + selectorSource, + null, + initialReconnectDelay + ); + } + } + + private DataSystemComponents() {} + + /** + * Get a builder for a polling initializer. + * + * @return the polling initializer builder + */ + public static FDv2PollingInitializerBuilder pollingInitializer() { + return new FDv2PollingInitializerBuilderImpl(); + } + + /** + * Get a builder for a polling synchronizer. + * + * @return the polling synchronizer builder + */ + public static FDv2PollingSynchronizerBuilder pollingSynchronizer() { + return new FDv2PollingSynchronizerBuilderImpl(); + } + + /** + * Get a builder for a streaming synchronizer. + * + * @return the streaming synchronizer builder + */ + public static FDv2StreamingSynchronizerBuilder streamingSynchronizer() { + return new FDv2StreamingSynchronizerBuilderImpl(); + } + + /** + * Get a builder for a FDv1 compatible polling data source. + *

+ * This is intended for use as a fallback. + *

+ * + * @return the FDv1 compatible polling data source builder + */ + public static PollingDataSourceBuilder fDv1Polling() { + return Components.pollingDataSource(); + } +} + diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java index 290be0b3..20429027 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java @@ -31,8 +31,7 @@ * Implementation of FDv2Requestor for polling feature flag data via FDv2 protocol. */ public class DefaultFDv2Requestor implements FDv2Requestor, Closeable { - private static final String VERSION_QUERY_PARAM = "version"; - private static final String STATE_QUERY_PARAM = "state"; + private static final String BASIS_QUERY_PARAM = "version"; private final OkHttpClient httpClient; private final URI pollingUri; @@ -67,11 +66,7 @@ public CompletableFuture Poll(Selector selector) { URI requestUri = pollingUri; if (!selector.isEmpty()) { - requestUri = HttpHelpers.addQueryParam(requestUri, VERSION_QUERY_PARAM, String.valueOf(selector.getVersion())); - } - - if (selector.getState() != null && !selector.getState().isEmpty()) { - requestUri = HttpHelpers.addQueryParam(requestUri, STATE_QUERY_PARAM, selector.getState()); + requestUri = HttpHelpers.addQueryParam(requestUri, BASIS_QUERY_PARAM, selector.getState()); } logger.debug("Making FDv2 polling request to: {}", requestUri); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java index 71bf1128..835111ae 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java @@ -1,11 +1,13 @@ package com.launchdarkly.sdk.server; +import com.google.common.collect.ImmutableList; import com.launchdarkly.sdk.server.datasources.FDv2SourceResult; import com.launchdarkly.sdk.server.datasources.Initializer; import com.launchdarkly.sdk.server.datasources.Synchronizer; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.subsystems.DataSource; import com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSink; +import com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSinkV2; import java.io.Closeable; import java.io.IOException; @@ -21,7 +23,7 @@ class FDv2DataSource implements DataSource { private final List initializers; private final List synchronizers; - private final DataSourceUpdateSink dataSourceUpdates; + private final DataSourceUpdateSinkV2 dataSourceUpdates; private final CompletableFuture startFuture = new CompletableFuture<>(); private final AtomicBoolean started = new AtomicBoolean(false); @@ -78,9 +80,9 @@ public interface SynchronizerFactory { public FDv2DataSource( - List initializers, - List synchronizers, - DataSourceUpdateSink dataSourceUpdates + ImmutableList initializers, + ImmutableList synchronizers, + DataSourceUpdateSinkV2 dataSourceUpdates ) { this.initializers = initializers; this.synchronizers = synchronizers @@ -116,6 +118,40 @@ private SynchronizerFactoryWithState getFirstAvailableSynchronizer() { } } + private void runInitializers() { + boolean anyDataReceived = false; + for (InitializerFactory factory : initializers) { + try { + Initializer initializer = factory.build(); + if (setActiveSource(initializer)) return; + FDv2SourceResult result = initializer.run().get(); + switch (result.getResultType()) { + case CHANGE_SET: + dataSourceUpdates.apply(result.getChangeSet()); + anyDataReceived = true; + if (!result.getChangeSet().getSelector().isEmpty()) { + // We received data with a selector, so we end the initialization process. + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); + startFuture.complete(true); + return; + } + break; + case STATUS: + // TODO: Implement. + break; + } + } catch (ExecutionException | InterruptedException | CancellationException e) { + // TODO: Log. + } + } + // We received data without a selector, and we have exhausted initializers, so we are going to + // consider ourselves initialized. + if (anyDataReceived) { + dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); + startFuture.complete(true); + } + } + private void runSynchronizers() { SynchronizerFactoryWithState availableSynchronizer = getFirstAvailableSynchronizer(); // TODO: Add recovery handling. If there are no available synchronizers, but there are @@ -130,7 +166,7 @@ private void runSynchronizers() { FDv2SourceResult result = synchronizer.next().get(); switch (result.getResultType()) { case CHANGE_SET: - // TODO: Apply to the store. + dataSourceUpdates.apply(result.getChangeSet()); // This could have been completed by any data source. But if it has not been completed before // now, then we complete it. startFuture.complete(true); @@ -186,40 +222,6 @@ private boolean setActiveSource(Closeable synchronizer) { return false; } - private void runInitializers() { - boolean anyDataReceived = false; - for (InitializerFactory factory : initializers) { - try { - Initializer initializer = factory.build(); - if (setActiveSource(initializer)) return; - FDv2SourceResult res = initializer.run().get(); - switch (res.getResultType()) { - case CHANGE_SET: - // TODO: Apply to the store. - anyDataReceived = true; - if (!res.getChangeSet().getSelector().isEmpty()) { - // We received data with a selector, so we end the initialization process. - dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); - startFuture.complete(true); - return; - } - break; - case STATUS: - // TODO: Implement. - break; - } - } catch (ExecutionException | InterruptedException | CancellationException e) { - // TODO: Log. - } - } - // We received data without a selector, and we have exhausted initializers, so we are going to - // consider ourselves initialized. - if (anyDataReceived) { - dataSourceUpdates.updateStatus(DataSourceStatusProvider.State.VALID, null); - startFuture.complete(true); - } - } - @Override public Future start() { if (!started.getAndSet(true)) { diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java index 1481de1d..f917a2d6 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java @@ -1,14 +1,19 @@ package com.launchdarkly.sdk.server; +import com.google.common.collect.ImmutableList; import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.server.datasources.Initializer; +import com.launchdarkly.sdk.server.datasources.SelectorSource; +import com.launchdarkly.sdk.server.datasources.Synchronizer; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; -import com.launchdarkly.sdk.server.subsystems.DataSource; -import com.launchdarkly.sdk.server.subsystems.DataStore; -import com.launchdarkly.sdk.server.subsystems.LoggingConfiguration; +import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; +import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; +import com.launchdarkly.sdk.server.subsystems.*; import java.io.Closeable; import java.io.IOException; +import java.util.LinkedList; import java.util.concurrent.Future; /** @@ -28,11 +33,11 @@ final class FDv2DataSystem implements DataSystem, Closeable { private boolean disposed = false; private FDv2DataSystem( - DataStore store, - DataSource dataSource, - DataSourceStatusProvider dataSourceStatusProvider, - DataStoreStatusProvider dataStoreStatusProvider, - FlagChangeNotifier flagChanged + DataStore store, + DataSource dataSource, + DataSourceStatusProvider dataSourceStatusProvider, + DataStoreStatusProvider dataStoreStatusProvider, + FlagChangeNotifier flagChanged ) { this.store = store; this.dataSource = dataSource; @@ -42,31 +47,117 @@ private FDv2DataSystem( this.readOnlyStore = new ReadonlyStoreFacade(store); } + private static class SynchronizerFactoryWrapper implements FDv2DataSource.SynchronizerFactory { + + private final SynchronizerBuilder builder; + private final ClientContext context; + private final SelectorSource selectorSource; + + public SynchronizerFactoryWrapper(SynchronizerBuilder builder, ClientContext context, SelectorSource selectorSource) { + this.builder = builder; + this.context = context; + this.selectorSource = selectorSource; + } + + @Override + public Synchronizer build() { + return builder.build(context, selectorSource); + } + } + + private static class InitializerFactoryWrapper implements FDv2DataSource.InitializerFactory { + + private final InitializerBuilder builder; + private final ClientContext context; + private final SelectorSource selectorSource; + + public InitializerFactoryWrapper(InitializerBuilder builder, ClientContext context, SelectorSource selectorSource) { + this.builder = builder; + this.context = context; + this.selectorSource = selectorSource; + } + + @Override + public Initializer build() { + return builder.build(context, selectorSource); + } + } + /** * Creates a new FDv2DataSystem instance. *

* This is a placeholder implementation. Not all dependencies are yet implemented. * - * @param logger the logger - * @param config the SDK configuration + * @param logger the logger + * @param config the SDK configuration * @param clientContext the client context - * @param logConfig the logging configuration + * @param logConfig the logging configuration * @return a new FDv2DataSystem instance * @throws UnsupportedOperationException since this is not yet fully implemented */ static FDv2DataSystem create( - LDLogger logger, - LDConfig config, - ClientContextImpl clientContext, - LoggingConfiguration logConfig + LDLogger logger, + LDConfig config, + ClientContextImpl clientContext, + LoggingConfiguration logConfig ) { if (config.dataSystem == null) { throw new IllegalArgumentException("DataSystem configuration is required for FDv2DataSystem"); } - - // TODO: Implement FDv2DataSystem once all dependencies are available - - throw new UnsupportedOperationException("FDv2DataSystem is not yet fully implemented"); + DataStoreUpdatesImpl dataStoreUpdates = new DataStoreUpdatesImpl( + EventBroadcasterImpl.forDataStoreStatus(clientContext.sharedExecutor, logger)); + + InMemoryDataStore store = new InMemoryDataStore(); + + DataStoreStatusProvider dataStoreStatusProvider = new DataStoreStatusProviderImpl(store, dataStoreUpdates); + + // Create a single flag change broadcaster to be shared between DataSourceUpdatesImpl and FlagTrackerImpl + EventBroadcasterImpl flagChangeBroadcaster = + EventBroadcasterImpl.forFlagChangeEvents(clientContext.sharedExecutor, logger); + + // Create a single data source status broadcaster to be shared between DataSourceUpdatesImpl and DataSourceStatusProviderImpl + EventBroadcasterImpl dataSourceStatusBroadcaster = + EventBroadcasterImpl.forDataSourceStatus(clientContext.sharedExecutor, logger); + + DataSourceUpdatesImpl dataSourceUpdates = new DataSourceUpdatesImpl( + store, + dataStoreStatusProvider, + flagChangeBroadcaster, + dataSourceStatusBroadcaster, + clientContext.sharedExecutor, + logConfig.getLogDataSourceOutageAsErrorAfter(), + logger + ); + + DataSystemConfiguration dataSystemConfiguration = config.dataSystem.build(); + SelectorSource selectorSource = new SelectorSourceFacade(store); + + ImmutableList initializerFactories = dataSystemConfiguration.getInitializers().stream() + .map(initializer -> new InitializerFactoryWrapper(initializer, clientContext, selectorSource)) + .collect(ImmutableList.toImmutableList()); + + ImmutableList synchronizerFactories = dataSystemConfiguration.getSynchronizers().stream() + .map(synchronizer -> new SynchronizerFactoryWrapper(synchronizer, clientContext, selectorSource)) + .collect(ImmutableList.toImmutableList()); + + DataSource dataSource = new FDv2DataSource( + initializerFactories, + synchronizerFactories, + dataSourceUpdates + ); + DataSourceStatusProvider dataSourceStatusProvider = new DataSourceStatusProviderImpl( + dataSourceStatusBroadcaster, + dataSourceUpdates); + + FlagChangeNotifier flagChanged = new FlagChangedFacade(dataSourceUpdates); + + return new FDv2DataSystem( + store, + dataSource, + dataSourceStatusProvider, + dataStoreStatusProvider, + flagChanged + ); } @Override @@ -76,8 +167,7 @@ public ReadOnlyStore getStore() { @Override public Future start() { - // TODO: Implement FDv2DataSystem.start() once all dependencies are available - throw new UnsupportedOperationException("FDv2DataSystem.start() is not yet implemented"); + return dataSource.start(); } @Override diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/SelectorSourceFacade.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/SelectorSourceFacade.java new file mode 100644 index 00000000..49b26bb7 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/SelectorSourceFacade.java @@ -0,0 +1,19 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.internal.fdv2.sources.Selector; +import com.launchdarkly.sdk.server.datasources.SelectorSource; +import com.launchdarkly.sdk.server.subsystems.TransactionalDataStore; + +// TODO: Implement directly on store? + +class SelectorSourceFacade implements SelectorSource { + private final TransactionalDataStore store; + public SelectorSourceFacade(TransactionalDataStore store) { + this.store = store; + } + + @Override + public Selector getSelector() { + return store.getSelector(); + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java index 7d5b5580..c5d52f3d 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/StreamingSynchronizerImpl.java @@ -98,10 +98,7 @@ private void startStream() { // Add selector query parameters if the selector is not empty if (!selector.isEmpty()) { - updatedUri = HttpHelpers.addQueryParam(updatedUri, "version", String.valueOf(selector.getVersion())); - if (selector.getState() != null && !selector.getState().isEmpty()) { - updatedUri = HttpHelpers.addQueryParam(updatedUri, "state", selector.getState()); - } + updatedUri = HttpHelpers.addQueryParam(updatedUri, "basis", selector.getState()); } // Add the payloadFilter query parameter if present and non-empty diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java index 6fd6efd3..eb7eb9a6 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java @@ -3,10 +3,7 @@ import com.google.common.collect.ImmutableList; import com.launchdarkly.sdk.server.datasources.Initializer; import com.launchdarkly.sdk.server.datasources.Synchronizer; -import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; -import com.launchdarkly.sdk.server.subsystems.DataSource; -import com.launchdarkly.sdk.server.subsystems.DataStore; -import com.launchdarkly.sdk.server.subsystems.DataSystemConfiguration; +import com.launchdarkly.sdk.server.subsystems.*; import java.util.ArrayList; import java.util.List; @@ -20,22 +17,22 @@ */ public final class DataSystemBuilder { - private final List> initializers = new ArrayList<>(); - private final List> synchronizers = new ArrayList<>(); - private ComponentConfigurer fDv1FallbackSynchronizer; + private final List initializers = new ArrayList<>(); + private final List synchronizers = new ArrayList<>(); + private ComponentConfigurer fDv1FallbackSynchronizer; private ComponentConfigurer persistentStore; private DataSystemConfiguration.DataStoreMode persistentDataStoreMode; /** * Add one or more initializers to the builder. - * To replace initializers, please refer to {@link #replaceInitializers(ComponentConfigurer[])}. + * To replace initializers, please refer to {@link #replaceInitializers(InitializerBuilder[])}. * * @param initializers the initializers to add * @return a reference to the builder */ @SafeVarargs - public final DataSystemBuilder initializers(ComponentConfigurer... initializers) { - for (ComponentConfigurer initializer : initializers) { + public final DataSystemBuilder initializers(InitializerBuilder... initializers) { + for (InitializerBuilder initializer : initializers) { this.initializers.add(initializer); } return this; @@ -43,15 +40,15 @@ public final DataSystemBuilder initializers(ComponentConfigurer... /** * Replaces any existing initializers with the given initializers. - * To add initializers, please refer to {@link #initializers(ComponentConfigurer[])}. + * To add initializers, please refer to {@link #initializers(InitializerBuilder[])}. * * @param initializers the initializers to replace the current initializers with * @return a reference to this builder */ @SafeVarargs - public final DataSystemBuilder replaceInitializers(ComponentConfigurer... initializers) { + public final DataSystemBuilder replaceInitializers(InitializerBuilder... initializers) { this.initializers.clear(); - for (ComponentConfigurer initializer : initializers) { + for (InitializerBuilder initializer : initializers) { this.initializers.add(initializer); } return this; @@ -59,14 +56,14 @@ public final DataSystemBuilder replaceInitializers(ComponentConfigurer... synchronizers) { - for (ComponentConfigurer synchronizer : synchronizers) { + public final DataSystemBuilder synchronizers(SynchronizerBuilder... synchronizers) { + for (SynchronizerBuilder synchronizer : synchronizers) { this.synchronizers.add(synchronizer); } return this; @@ -74,15 +71,15 @@ public final DataSystemBuilder synchronizers(ComponentConfigurer.. /** * Replaces any existing synchronizers with the given synchronizers. - * To add synchronizers, please refer to {@link #synchronizers(ComponentConfigurer[])}. + * To add synchronizers, please refer to {@link #synchronizers(SynchronizerBuilder[])}. * * @param synchronizers the synchronizers to replace the current synchronizers with * @return a reference to this builder */ @SafeVarargs - public final DataSystemBuilder replaceSynchronizers(ComponentConfigurer... synchronizers) { + public final DataSystemBuilder replaceSynchronizers(SynchronizerBuilder... synchronizers) { this.synchronizers.clear(); - for (ComponentConfigurer synchronizer : synchronizers) { + for (SynchronizerBuilder synchronizer : synchronizers) { this.synchronizers.add(synchronizer); } return this; @@ -101,7 +98,7 @@ public final DataSystemBuilder replaceSynchronizers(ComponentConfigurer fDv1FallbackSynchronizer) { // Legacy DataSource configurers are used for FDv1 backward compatibility // This is safe because DataSource is only used in the fallback context - this.fDv1FallbackSynchronizer = (ComponentConfigurer) (ComponentConfigurer) fDv1FallbackSynchronizer; + this.fDv1FallbackSynchronizer = fDv1FallbackSynchronizer; return this; } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemComponents.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemComponents.java deleted file mode 100644 index e26f5585..00000000 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemComponents.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.launchdarkly.sdk.server.integrations; - -import com.launchdarkly.sdk.server.Components; - -/** - * Components for use with the data system. - *

- * This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. - * It is in early access. If you want access to this feature please join the EAP. https://launchdarkly.com/docs/sdk/features/data-saving-mode - *

- */ -public final class DataSystemComponents { - - private DataSystemComponents() {} - - /** - * Get a builder for a polling initializer. - * - * @return the polling initializer builder - */ - public static FDv2PollingInitializerBuilder pollingInitializer() { - return new FDv2PollingInitializerBuilder(); - } - - /** - * Get a builder for a polling synchronizer. - * - * @return the polling synchronizer builder - */ - public static FDv2PollingSynchronizerBuilder pollingSynchronizer() { - return new FDv2PollingSynchronizerBuilder(); - } - - /** - * Get a builder for a streaming synchronizer. - * - * @return the streaming synchronizer builder - */ - public static FDv2StreamingSynchronizerBuilder streamingSynchronizer() { - return new FDv2StreamingSynchronizerBuilder(); - } - - /** - * Get a builder for a FDv1 compatible polling data source. - *

- * This is intended for use as a fallback. - *

- * - * @return the FDv1 compatible polling data source builder - */ - public static PollingDataSourceBuilder fDv1Polling() { - return Components.pollingDataSource(); - } -} - diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemModes.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemModes.java index 1d34f4dd..8d2d9b4e 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemModes.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemModes.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server.integrations; +import com.launchdarkly.sdk.server.DataSystemComponents; import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.server.subsystems.DataStore; import com.launchdarkly.sdk.server.subsystems.DataSystemConfiguration; diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingInitializerBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingInitializerBuilder.java index a824ae90..670d5afc 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingInitializerBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingInitializerBuilder.java @@ -3,14 +3,11 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.internal.events.DiagnosticConfigProperty; import com.launchdarkly.sdk.server.StandardEndpoints; -import com.launchdarkly.sdk.server.datasources.Initializer; import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; import com.launchdarkly.sdk.server.subsystems.ClientContext; -import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.server.subsystems.DiagnosticDescription; +import com.launchdarkly.sdk.server.subsystems.InitializerBuilder; -import java.net.URI; -import java.time.Duration; /** * Contains methods for configuring the polling initializer. @@ -31,43 +28,8 @@ * .fDv1FallbackSynchronizer(DataSystemComponents.fDv1Polling())); * */ -public final class FDv2PollingInitializerBuilder implements ComponentConfigurer, DiagnosticDescription { - /** - * The default value for {@link #pollInterval(Duration)}: 30 seconds. - */ - public static final Duration DEFAULT_POLL_INTERVAL = Duration.ofSeconds(30); - - Duration pollInterval = DEFAULT_POLL_INTERVAL; - - private ServiceEndpoints serviceEndpointsOverride; - - /** - * Sets the interval at which the SDK will poll for feature flag updates. - *

- * The default and minimum value is {@link #DEFAULT_POLL_INTERVAL}. Values less than this will - * be set to the default. - *

- * - * @param pollInterval the polling interval - * @return the builder - */ - public FDv2PollingInitializerBuilder pollInterval(Duration pollInterval) { - this.pollInterval = pollInterval != null && pollInterval.compareTo(DEFAULT_POLL_INTERVAL) >= 0 - ? pollInterval - : DEFAULT_POLL_INTERVAL; - return this; - } - - /** - * Exposed internally for testing. - * - * @param pollInterval the polling interval - * @return the builder - */ - FDv2PollingInitializerBuilder pollIntervalNoMinimum(Duration pollInterval) { - this.pollInterval = pollInterval; - return this; - } +public abstract class FDv2PollingInitializerBuilder implements InitializerBuilder, DiagnosticDescription { + protected ServiceEndpoints serviceEndpointsOverride; /** * Sets overrides for the service endpoints. In typical usage, the initializer will use the commonly defined @@ -82,45 +44,19 @@ public FDv2PollingInitializerBuilder serviceEndpointsOverride(ServiceEndpointsBu return this; } - @Override - public Initializer build(ClientContext context) { - ServiceEndpoints endpoints = serviceEndpointsOverride != null - ? serviceEndpointsOverride - : context.getServiceEndpoints(); - URI configuredBaseUri = StandardEndpoints.selectBaseUri( - endpoints.getPollingBaseUri(), - StandardEndpoints.DEFAULT_POLLING_BASE_URI, - "Polling", - context.getBaseLogger()); - - // TODO: Implement FDv2Requestor - // var requestor = new FDv2RequestorImpl(context, configuredBaseUri); - - // TODO: Implement PollingInitializer with FDv2Requestor - // return new PollingInitializerImpl( - // requestor, - // context.getBaseLogger(), - // context.getSelectorSource() - // ); - - // Placeholder - this will not compile until FDv2Requestor is implemented - throw new UnsupportedOperationException("FDv2Requestor is not yet implemented"); - } - @Override public LDValue describeConfiguration(ClientContext context) { ServiceEndpoints endpoints = serviceEndpointsOverride != null - ? serviceEndpointsOverride - : context.getServiceEndpoints(); + ? serviceEndpointsOverride + : context.getServiceEndpoints(); boolean customPollingBaseUri = StandardEndpoints.isCustomBaseUri( - endpoints.getPollingBaseUri(), StandardEndpoints.DEFAULT_POLLING_BASE_URI); + endpoints.getPollingBaseUri(), StandardEndpoints.DEFAULT_POLLING_BASE_URI); return LDValue.buildObject() - .put(DiagnosticConfigProperty.STREAMING_DISABLED.name, true) - .put(DiagnosticConfigProperty.CUSTOM_BASE_URI.name, customPollingBaseUri) - .put(DiagnosticConfigProperty.POLLING_INTERVAL_MILLIS.name, pollInterval.toMillis()) - .put(DiagnosticConfigProperty.USING_RELAY_DAEMON.name, false) - .build(); + .put(DiagnosticConfigProperty.STREAMING_DISABLED.name, true) + .put(DiagnosticConfigProperty.CUSTOM_BASE_URI.name, customPollingBaseUri) + .put(DiagnosticConfigProperty.USING_RELAY_DAEMON.name, false) + .build(); } } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingSynchronizerBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingSynchronizerBuilder.java index 22510311..70d08b0b 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingSynchronizerBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingSynchronizerBuilder.java @@ -8,6 +8,7 @@ import com.launchdarkly.sdk.server.subsystems.ClientContext; import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.server.subsystems.DiagnosticDescription; +import com.launchdarkly.sdk.server.subsystems.SynchronizerBuilder; import java.net.URI; import java.time.Duration; @@ -32,15 +33,15 @@ * .fDv1FallbackSynchronizer(DataSystemComponents.fDv1Polling())); * */ -public final class FDv2PollingSynchronizerBuilder implements ComponentConfigurer, DiagnosticDescription { +public abstract class FDv2PollingSynchronizerBuilder implements SynchronizerBuilder, DiagnosticDescription { /** * The default value for {@link #pollInterval(Duration)}: 30 seconds. */ public static final Duration DEFAULT_POLL_INTERVAL = Duration.ofSeconds(30); - Duration pollInterval = DEFAULT_POLL_INTERVAL; + protected Duration pollInterval = DEFAULT_POLL_INTERVAL; - private ServiceEndpoints serviceEndpointsOverride; + protected ServiceEndpoints serviceEndpointsOverride; /** * Sets the interval at which the SDK will poll for feature flag updates. @@ -83,33 +84,6 @@ public FDv2PollingSynchronizerBuilder serviceEndpointsOverride(ServiceEndpointsB return this; } - @Override - public Synchronizer build(ClientContext context) { - ServiceEndpoints endpoints = serviceEndpointsOverride != null - ? serviceEndpointsOverride - : context.getServiceEndpoints(); - URI configuredBaseUri = StandardEndpoints.selectBaseUri( - endpoints.getPollingBaseUri(), - StandardEndpoints.DEFAULT_POLLING_BASE_URI, - "Polling", - context.getBaseLogger()); - - // TODO: Implement FDv2Requestor - // var requestor = new FDv2RequestorImpl(context, configuredBaseUri); - - // TODO: Implement PollingSynchronizer with FDv2Requestor - // return new PollingSynchronizerImpl( - // requestor, - // context.getBaseLogger(), - // context.getSelectorSource(), - // context.getSharedExecutor(), - // pollInterval - // ); - - // Placeholder - this will not compile until FDv2Requestor is implemented - throw new UnsupportedOperationException("FDv2Requestor is not yet implemented"); - } - @Override public LDValue describeConfiguration(ClientContext context) { ServiceEndpoints endpoints = serviceEndpointsOverride != null diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingSynchronizerBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingSynchronizerBuilder.java index 89fa8f4b..129c8cd1 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingSynchronizerBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingSynchronizerBuilder.java @@ -8,6 +8,7 @@ import com.launchdarkly.sdk.server.subsystems.ClientContext; import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.server.subsystems.DiagnosticDescription; +import com.launchdarkly.sdk.server.subsystems.SynchronizerBuilder; import java.net.URI; import java.time.Duration; @@ -31,15 +32,15 @@ * .fDv1FallbackSynchronizer(DataSystemComponents.fDv1Polling())); * */ -public final class FDv2StreamingSynchronizerBuilder implements ComponentConfigurer, DiagnosticDescription { +public abstract class FDv2StreamingSynchronizerBuilder implements SynchronizerBuilder, DiagnosticDescription { /** * The default value for {@link #initialReconnectDelay(Duration)}: 1000 milliseconds. */ public static final Duration DEFAULT_INITIAL_RECONNECT_DELAY = Duration.ofSeconds(1); - private Duration initialReconnectDelay = DEFAULT_INITIAL_RECONNECT_DELAY; + protected Duration initialReconnectDelay = DEFAULT_INITIAL_RECONNECT_DELAY; - private ServiceEndpoints serviceEndpointsOverride; + protected ServiceEndpoints serviceEndpointsOverride; /** * Sets the initial reconnect delay for the streaming connection. @@ -73,30 +74,6 @@ public FDv2StreamingSynchronizerBuilder serviceEndpointsOverride(ServiceEndpoint return this; } - @Override - public Synchronizer build(ClientContext context) { - ServiceEndpoints endpoints = serviceEndpointsOverride != null - ? serviceEndpointsOverride - : context.getServiceEndpoints(); - URI configuredBaseUri = StandardEndpoints.selectBaseUri( - endpoints.getStreamingBaseUri(), - StandardEndpoints.DEFAULT_STREAMING_BASE_URI, - "Streaming", - context.getBaseLogger()); - - // TODO: Implement FDv2StreamingSynchronizer - // return new StreamingSynchronizerImpl( - // context.getHttp(), - // configuredBaseUri, - // StandardEndpoints.FDV2_STREAMING_REQUEST_PATH, - // context.getBaseLogger(), - // context.getSelectorSource() - // ); - - // Placeholder - this will not compile until FDv2StreamingSynchronizer is fully integrated - throw new UnsupportedOperationException("FDv2StreamingSynchronizer is not yet implemented"); - } - @Override public LDValue describeConfiguration(ClientContext context) { ServiceEndpoints endpoints = serviceEndpointsOverride != null diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSystemConfiguration.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSystemConfiguration.java index ec7cebdb..be3ce4b0 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSystemConfiguration.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSystemConfiguration.java @@ -37,9 +37,9 @@ public enum DataStoreMode { READ_WRITE } - private final ImmutableList> initializers; - private final ImmutableList> synchronizers; - private final ComponentConfigurer fDv1FallbackSynchronizer; + private final ImmutableList initializers; + private final ImmutableList synchronizers; + private final ComponentConfigurer fDv1FallbackSynchronizer; private final ComponentConfigurer persistentStore; private final DataStoreMode persistentDataStoreMode; @@ -56,9 +56,9 @@ public enum DataStoreMode { * @param persistentDataStoreMode see {@link #getPersistentDataStoreMode()} */ public DataSystemConfiguration( - ImmutableList> initializers, - ImmutableList> synchronizers, - ComponentConfigurer fDv1FallbackSynchronizer, + ImmutableList initializers, + ImmutableList synchronizers, + ComponentConfigurer fDv1FallbackSynchronizer, ComponentConfigurer persistentStore, DataStoreMode persistentDataStoreMode) { this.initializers = initializers; @@ -73,7 +73,7 @@ public DataSystemConfiguration( * * @return the list of initializer configurers */ - public ImmutableList> getInitializers() { + public ImmutableList getInitializers() { return initializers; } @@ -82,7 +82,7 @@ public ImmutableList> getInitializers() { * * @return the list of synchronizer configurers */ - public ImmutableList> getSynchronizers() { + public ImmutableList getSynchronizers() { return synchronizers; } @@ -91,7 +91,7 @@ public ImmutableList> getSynchronizers() { * * @return the FDv1 fallback synchronizer configurer, or null */ - public ComponentConfigurer getFDv1FallbackSynchronizer() { + public ComponentConfigurer getFDv1FallbackSynchronizer() { return fDv1FallbackSynchronizer; } @@ -100,7 +100,7 @@ public ComponentConfigurer getFDv1FallbackSynchronizer() { * null. *

* The persistent store itself will implement {@link PersistentDataStore}, but we expect that to be wrapped by a factory which can - * operates at the {@link DataStore} level. + * operate at the {@link DataStore} level. *

* * @return the persistent store configurer, or null diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/InitializerBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/InitializerBuilder.java new file mode 100644 index 00000000..636d925b --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/InitializerBuilder.java @@ -0,0 +1,8 @@ +package com.launchdarkly.sdk.server.subsystems; + +import com.launchdarkly.sdk.server.datasources.Initializer; +import com.launchdarkly.sdk.server.datasources.SelectorSource; + +public interface InitializerBuilder { + Initializer build(ClientContext context, SelectorSource selectorSource); +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/SynchronizerBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/SynchronizerBuilder.java new file mode 100644 index 00000000..1be9de5e --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/SynchronizerBuilder.java @@ -0,0 +1,8 @@ +package com.launchdarkly.sdk.server.subsystems; + +import com.launchdarkly.sdk.server.datasources.SelectorSource; +import com.launchdarkly.sdk.server.datasources.Synchronizer; + +public interface SynchronizerBuilder { + Synchronizer build(ClientContext context, SelectorSource selectorSource); +} diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/ConfigurationTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/ConfigurationTest.java index d06c5302..76aa11e3 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/ConfigurationTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/ConfigurationTest.java @@ -1,7 +1,6 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.sdk.server.integrations.DataSystemBuilder; -import com.launchdarkly.sdk.server.integrations.DataSystemComponents; import com.launchdarkly.sdk.server.integrations.DataSystemModes; import com.launchdarkly.sdk.server.integrations.FDv2PollingInitializerBuilder; import com.launchdarkly.sdk.server.integrations.FDv2PollingSynchronizerBuilder; @@ -213,14 +212,12 @@ public void canAddMultipleSynchronizersToCustomDataSystem() { DataSystemModes modes = new DataSystemModes(); DataSystemBuilder builder = modes.custom() .synchronizers(DataSystemComponents.pollingSynchronizer()) - .synchronizers(DataSystemComponents.streamingSynchronizer()) - .synchronizers(DataSystemComponents.fDv1Polling()); + .synchronizers(DataSystemComponents.streamingSynchronizer()); DataSystemConfiguration dataSystemConfig = builder.build(); - assertEquals(3, dataSystemConfig.getSynchronizers().size()); + assertEquals(2, dataSystemConfig.getSynchronizers().size()); assertTrue(dataSystemConfig.getSynchronizers().get(0) instanceof FDv2PollingSynchronizerBuilder); assertTrue(dataSystemConfig.getSynchronizers().get(1) instanceof FDv2StreamingSynchronizerBuilder); - assertTrue(dataSystemConfig.getSynchronizers().get(2) instanceof PollingDataSourceBuilder); } @Test From ae39996e8b8f82c6ee501658c80e5098260e0c22 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 20 Jan 2026 09:23:01 -0800 Subject: [PATCH 41/48] Fix data source selector related tests. --- .../sdk/server/DefaultFDv2Requestor.java | 2 +- .../sdk/server/DefaultFDv2RequestorTest.java | 23 +++++++++---------- .../server/StreamingSynchronizerImplTest.java | 19 +++++---------- 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java index 20429027..133a56aa 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DefaultFDv2Requestor.java @@ -31,7 +31,7 @@ * Implementation of FDv2Requestor for polling feature flag data via FDv2 protocol. */ public class DefaultFDv2Requestor implements FDv2Requestor, Closeable { - private static final String BASIS_QUERY_PARAM = "version"; + private static final String BASIS_QUERY_PARAM = "basis"; private final OkHttpClient httpClient; private final URI pollingUri; diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFDv2RequestorTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFDv2RequestorTest.java index ea3a77a7..53914fea 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFDv2RequestorTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFDv2RequestorTest.java @@ -136,12 +136,12 @@ public void emptyEventsArray() throws Exception { } @Test - public void requestWithVersionQueryParameter() throws Exception { + public void requestWithBasisQueryParameter() throws Exception { Handler resp = Handlers.bodyJson(EMPTY_EVENTS_JSON); try (HttpServer server = HttpServer.start(resp)) { try (DefaultFDv2Requestor requestor = makeRequestor(server)) { - Selector selector = Selector.make(42, null); + Selector selector = Selector.make(42, "test-state"); CompletableFuture future = requestor.Poll(selector); @@ -150,18 +150,18 @@ public void requestWithVersionQueryParameter() throws Exception { RequestInfo req = server.getRecorder().requireRequest(); assertEquals(REQUEST_PATH, req.getPath()); - assertThat(req.getQuery(), containsString("version=42")); + assertThat(req.getQuery(), containsString("basis=test-state")); } } } @Test - public void requestWithStateQueryParameter() throws Exception { + public void requestWithBasisContainingState() throws Exception { Handler resp = Handlers.bodyJson(EMPTY_EVENTS_JSON); try (HttpServer server = HttpServer.start(resp)) { try (DefaultFDv2Requestor requestor = makeRequestor(server)) { - Selector selector = Selector.make(0, "test-state"); + Selector selector = Selector.make(0, "(p:payload-1:100)"); CompletableFuture future = requestor.Poll(selector); @@ -170,18 +170,18 @@ public void requestWithStateQueryParameter() throws Exception { RequestInfo req = server.getRecorder().requireRequest(); assertEquals(REQUEST_PATH, req.getPath()); - assertThat(req.getQuery(), containsString("state=test-state")); + assertThat(req.getQuery(), containsString("basis=%28p%3Apayload-1%3A100%29")); } } } @Test - public void requestWithBothQueryParameters() throws Exception { + public void requestWithComplexBasisState() throws Exception { Handler resp = Handlers.bodyJson(EMPTY_EVENTS_JSON); try (HttpServer server = HttpServer.start(resp)) { try (DefaultFDv2Requestor requestor = makeRequestor(server)) { - Selector selector = Selector.make(100, "my-state"); + Selector selector = Selector.make(100, "(p:my-payload:200)"); CompletableFuture future = requestor.Poll(selector); @@ -190,8 +190,7 @@ public void requestWithBothQueryParameters() throws Exception { RequestInfo req = server.getRecorder().requireRequest(); assertEquals(REQUEST_PATH, req.getPath()); - assertThat(req.getQuery(), containsString("version=100")); - assertThat(req.getQuery(), containsString("state=my-state")); + assertThat(req.getQuery(), containsString("basis=%28p%3FindAmy-payload%3A200%29")); } } } @@ -403,8 +402,8 @@ public void differentSelectorsUseDifferentEtags() throws Exception { try (HttpServer server = HttpServer.start(resp)) { try (DefaultFDv2Requestor requestor = makeRequestor(server)) { - Selector selector1 = Selector.make(100, "state1"); - Selector selector2 = Selector.make(200, "state2"); + Selector selector1 = Selector.make(100, "(p:payload-1:100)"); + Selector selector2 = Selector.make(200, "(p:payload-2:200)"); // First request with selector1 requestor.Poll(selector1).get(5, TimeUnit.SECONDS); diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java index b720a26b..eaf305c3 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/StreamingSynchronizerImplTest.java @@ -403,11 +403,9 @@ public void selectorWithVersionAndState() throws Exception { // Verify selector was fetched when connecting verify(selectorSource, atLeastOnce()).getSelector(); - // Verify the request had the correct query parameters assertEquals(1, server.getRecorder().count()); RequestInfo request = server.getRecorder().requireRequest(); - assertThat(request.getQuery(), containsString("version=50")); - assertThat(request.getQuery(), containsString("state=")); + assertThat(request.getQuery(), containsString("basis=%28p%3Aold%3A50%29")); synchronizer.close(); } @@ -536,7 +534,7 @@ public void selectorWithVersionOnly() throws Exception { HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); SelectorSource selectorSource = mock(SelectorSource.class); - when(selectorSource.getSelector()).thenReturn(Selector.make(75, null)); + when(selectorSource.getSelector()).thenReturn(Selector.make(75, "(p:test:75)")); StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( httpProperties, @@ -554,11 +552,9 @@ public void selectorWithVersionOnly() throws Exception { assertNotNull(result); assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); - // Verify the request had version but not state parameter assertEquals(1, server.getRecorder().count()); RequestInfo request = server.getRecorder().requireRequest(); - assertThat(request.getQuery(), containsString("version=75")); - // State should not be present (or if present, not have an actual state value) + assertThat(request.getQuery(), containsString("basis=%28p%3Atest%3A75%29")); synchronizer.close(); } @@ -578,7 +574,7 @@ public void selectorWithEmptyState() throws Exception { HttpProperties httpProperties = toHttpProperties(clientContext("sdk-key", baseConfig().build()).getHttp()); SelectorSource selectorSource = mock(SelectorSource.class); - when(selectorSource.getSelector()).thenReturn(Selector.make(80, "")); + when(selectorSource.getSelector()).thenReturn(Selector.make(80, "(p:empty-test:80)")); StreamingSynchronizerImpl synchronizer = new StreamingSynchronizerImpl( httpProperties, @@ -596,10 +592,9 @@ public void selectorWithEmptyState() throws Exception { assertNotNull(result); assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); - // Verify the request had version but not state parameter (empty string shouldn't add state) assertEquals(1, server.getRecorder().count()); RequestInfo request = server.getRecorder().requireRequest(); - assertThat(request.getQuery(), containsString("version=80")); + assertThat(request.getQuery(), containsString("basis=%28p%3Aempty-test%3A80%29")); synchronizer.close(); } @@ -742,12 +737,10 @@ public void payloadFilterWithSelectorBothAddedToRequest() throws Exception { assertNotNull(result); assertEquals(FDv2SourceResult.ResultType.CHANGE_SET, result.getResultType()); - // Verify the request had both filter and selector parameters assertEquals(1, server.getRecorder().count()); RequestInfo request = server.getRecorder().requireRequest(); assertThat(request.getQuery(), containsString("filter=testFilter")); - assertThat(request.getQuery(), containsString("version=42")); - assertThat(request.getQuery(), containsString("state=")); + assertThat(request.getQuery(), containsString("basis=%28p%3Atest%3A42%29")); synchronizer.close(); } From 02c4c980e01789dacdb95e4fd3547cde09f949f9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 20 Jan 2026 09:47:35 -0800 Subject: [PATCH 42/48] Refactor initializer/synchronizer builder context. --- .../sdk/server/DataSystemComponents.java | 17 ++- .../sdk/server/FDv2DataSystem.java | 33 +++-- .../subsystems/DataSourceBuilderContext.java | 132 ++++++++++++++++++ .../server/subsystems/InitializerBuilder.java | 3 +- .../subsystems/SynchronizerBuilder.java | 3 +- .../sdk/server/DefaultFDv2RequestorTest.java | 2 +- 6 files changed, 164 insertions(+), 26 deletions(-) create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuilderContext.java diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystemComponents.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystemComponents.java index cb0f40cb..919a8fdc 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystemComponents.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystemComponents.java @@ -1,14 +1,13 @@ package com.launchdarkly.sdk.server; import com.launchdarkly.sdk.server.datasources.Initializer; -import com.launchdarkly.sdk.server.datasources.SelectorSource; import com.launchdarkly.sdk.server.datasources.Synchronizer; import com.launchdarkly.sdk.server.integrations.FDv2PollingInitializerBuilder; import com.launchdarkly.sdk.server.integrations.FDv2PollingSynchronizerBuilder; import com.launchdarkly.sdk.server.integrations.FDv2StreamingSynchronizerBuilder; import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; -import com.launchdarkly.sdk.server.subsystems.ClientContext; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuilderContext; import java.net.URI; @@ -25,7 +24,7 @@ public final class DataSystemComponents { static class FDv2PollingInitializerBuilderImpl extends FDv2PollingInitializerBuilder { @Override - public Initializer build(ClientContext context, SelectorSource selectorSource) { + public Initializer build(DataSourceBuilderContext context) { ServiceEndpoints endpoints = serviceEndpointsOverride != null ? serviceEndpointsOverride : context.getServiceEndpoints(); @@ -44,14 +43,14 @@ public Initializer build(ClientContext context, SelectorSource selectorSource) { return new PollingInitializerImpl( requestor, context.getBaseLogger(), - selectorSource + context.getSelectorSource() ); } } static class FDv2PollingSynchronizerBuilderImpl extends FDv2PollingSynchronizerBuilder { @Override - public Synchronizer build(ClientContext context, SelectorSource selectorSource) { + public Synchronizer build(DataSourceBuilderContext context) { ServiceEndpoints endpoints = serviceEndpointsOverride != null ? serviceEndpointsOverride : context.getServiceEndpoints(); @@ -70,8 +69,8 @@ public Synchronizer build(ClientContext context, SelectorSource selectorSource) return new PollingSynchronizerImpl( requestor, context.getBaseLogger(), - selectorSource, - ClientContextImpl.get(context).sharedExecutor, + context.getSelectorSource(), + context.getSharedExecutor(), pollInterval ); } @@ -79,7 +78,7 @@ public Synchronizer build(ClientContext context, SelectorSource selectorSource) static class FDv2StreamingSynchronizerBuilderImpl extends FDv2StreamingSynchronizerBuilder { @Override - public Synchronizer build(ClientContext context, SelectorSource selectorSource) { + public Synchronizer build(DataSourceBuilderContext context) { ServiceEndpoints endpoints = serviceEndpointsOverride != null ? serviceEndpointsOverride : context.getServiceEndpoints(); @@ -94,7 +93,7 @@ public Synchronizer build(ClientContext context, SelectorSource selectorSource) configuredBaseUri, StandardEndpoints.FDV2_STREAMING_REQUEST_PATH, context.getBaseLogger(), - selectorSource, + context.getSelectorSource(), null, initialReconnectDelay ); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java index f917a2d6..c07350c3 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java @@ -16,6 +16,8 @@ import java.util.LinkedList; import java.util.concurrent.Future; +import static com.launchdarkly.sdk.server.ComponentsImpl.toHttpProperties; + /** * Internal implementation of the FDv2 data system. *

@@ -50,36 +52,32 @@ private FDv2DataSystem( private static class SynchronizerFactoryWrapper implements FDv2DataSource.SynchronizerFactory { private final SynchronizerBuilder builder; - private final ClientContext context; - private final SelectorSource selectorSource; + private final DataSourceBuilderContext context; - public SynchronizerFactoryWrapper(SynchronizerBuilder builder, ClientContext context, SelectorSource selectorSource) { + public SynchronizerFactoryWrapper(SynchronizerBuilder builder, DataSourceBuilderContext context) { this.builder = builder; this.context = context; - this.selectorSource = selectorSource; } @Override public Synchronizer build() { - return builder.build(context, selectorSource); + return builder.build(context); } } private static class InitializerFactoryWrapper implements FDv2DataSource.InitializerFactory { private final InitializerBuilder builder; - private final ClientContext context; - private final SelectorSource selectorSource; + private final DataSourceBuilderContext context; - public InitializerFactoryWrapper(InitializerBuilder builder, ClientContext context, SelectorSource selectorSource) { + public InitializerFactoryWrapper(InitializerBuilder builder, DataSourceBuilderContext context) { this.builder = builder; this.context = context; - this.selectorSource = selectorSource; } @Override public Initializer build() { - return builder.build(context, selectorSource); + return builder.build(context); } } @@ -132,12 +130,23 @@ static FDv2DataSystem create( DataSystemConfiguration dataSystemConfiguration = config.dataSystem.build(); SelectorSource selectorSource = new SelectorSourceFacade(store); + DataSourceBuilderContext builderContext = new DataSourceBuilderContext( + clientContext.getBaseLogger(), + clientContext.getThreadPriority(), + dataSourceUpdates, + clientContext.getServiceEndpoints(), + clientContext.getHttp(), + clientContext.sharedExecutor, + clientContext.diagnosticStore, + selectorSource + ); + ImmutableList initializerFactories = dataSystemConfiguration.getInitializers().stream() - .map(initializer -> new InitializerFactoryWrapper(initializer, clientContext, selectorSource)) + .map(initializer -> new InitializerFactoryWrapper(initializer, builderContext)) .collect(ImmutableList.toImmutableList()); ImmutableList synchronizerFactories = dataSystemConfiguration.getSynchronizers().stream() - .map(synchronizer -> new SynchronizerFactoryWrapper(synchronizer, clientContext, selectorSource)) + .map(synchronizer -> new SynchronizerFactoryWrapper(synchronizer, builderContext)) .collect(ImmutableList.toImmutableList()); DataSource dataSource = new FDv2DataSource( diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuilderContext.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuilderContext.java new file mode 100644 index 00000000..eda63802 --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuilderContext.java @@ -0,0 +1,132 @@ +package com.launchdarkly.sdk.server.subsystems; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.internal.events.DiagnosticStore; +import com.launchdarkly.sdk.server.datasources.SelectorSource; +import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; + +import java.util.concurrent.ScheduledExecutorService; + +/** + * Context information provided to initializer and synchronizer builders. + *

+ * This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. + * It is in early access. If you want access to this feature, please join the EAP. https://launchdarkly.com/docs/sdk/features/data-saving-mode + *

+ * This consolidates all the parameters needed to construct data source components, + * including HTTP configuration, logging, scheduling, and selector state. + */ +public final class DataSourceBuilderContext { + private final LDLogger baseLogger; + private final int threadPriority; + private final DataSourceUpdateSink dataSourceUpdates; + private final ServiceEndpoints serviceEndpoints; + private final HttpConfiguration http; + private final ScheduledExecutorService sharedExecutor; + private final DiagnosticStore diagnosticStore; + private final SelectorSource selectorSource; + + /** + * Constructs a DataSourceBuilderContext. + * + * @param baseLogger the base logger instance + * @param threadPriority the thread priority for worker threads + * @param dataSourceUpdates the data source update sink + * @param serviceEndpoints the service endpoint URIs + * @param http HTTP configuration properties + * @param sharedExecutor shared executor service for scheduling + * @param diagnosticStore diagnostic data accumulator (may be null) + * @param selectorSource source for obtaining selectors + */ + public DataSourceBuilderContext( + LDLogger baseLogger, + int threadPriority, + DataSourceUpdateSink dataSourceUpdates, + ServiceEndpoints serviceEndpoints, + HttpConfiguration http, + ScheduledExecutorService sharedExecutor, + DiagnosticStore diagnosticStore, + SelectorSource selectorSource + ) { + this.baseLogger = baseLogger; + this.threadPriority = threadPriority; + this.dataSourceUpdates = dataSourceUpdates; + this.serviceEndpoints = serviceEndpoints; + this.http = http; + this.sharedExecutor = sharedExecutor; + this.diagnosticStore = diagnosticStore; + this.selectorSource = selectorSource; + } + + /** + * Returns the base logger instance. + * + * @return the base logger + */ + public LDLogger getBaseLogger() { + return baseLogger; + } + + /** + * Returns the thread priority for worker threads. + * + * @return the thread priority + */ + public int getThreadPriority() { + return threadPriority; + } + + /** + * Returns the data source update sink. + * + * @return the data source update sink + */ + public DataSourceUpdateSink getDataSourceUpdates() { + return dataSourceUpdates; + } + + /** + * Returns the service endpoint URIs. + * + * @return the service endpoints + */ + public ServiceEndpoints getServiceEndpoints() { + return serviceEndpoints; + } + + /** + * Returns the HTTP configuration properties. + * + * @return the HTTP configuration + */ + public HttpConfiguration getHttp() { + return http; + } + + /** + * Returns the shared executor service for scheduling. + * + * @return the shared executor + */ + public ScheduledExecutorService getSharedExecutor() { + return sharedExecutor; + } + + /** + * Returns the diagnostic data accumulator. + * + * @return the diagnostic store, or null if diagnostics are disabled + */ + public DiagnosticStore getDiagnosticStore() { + return diagnosticStore; + } + + /** + * Returns the selector source. + * + * @return the selector source + */ + public SelectorSource getSelectorSource() { + return selectorSource; + } +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/InitializerBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/InitializerBuilder.java index 636d925b..618f1046 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/InitializerBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/InitializerBuilder.java @@ -1,8 +1,7 @@ package com.launchdarkly.sdk.server.subsystems; import com.launchdarkly.sdk.server.datasources.Initializer; -import com.launchdarkly.sdk.server.datasources.SelectorSource; public interface InitializerBuilder { - Initializer build(ClientContext context, SelectorSource selectorSource); + Initializer build(DataSourceBuilderContext context); } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/SynchronizerBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/SynchronizerBuilder.java index 1be9de5e..e18f9ce7 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/SynchronizerBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/SynchronizerBuilder.java @@ -1,8 +1,7 @@ package com.launchdarkly.sdk.server.subsystems; -import com.launchdarkly.sdk.server.datasources.SelectorSource; import com.launchdarkly.sdk.server.datasources.Synchronizer; public interface SynchronizerBuilder { - Synchronizer build(ClientContext context, SelectorSource selectorSource); + Synchronizer build(DataSourceBuilderContext context); } diff --git a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFDv2RequestorTest.java b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFDv2RequestorTest.java index 53914fea..5ce321bb 100644 --- a/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFDv2RequestorTest.java +++ b/lib/sdk/server/src/test/java/com/launchdarkly/sdk/server/DefaultFDv2RequestorTest.java @@ -190,7 +190,7 @@ public void requestWithComplexBasisState() throws Exception { RequestInfo req = server.getRecorder().requireRequest(); assertEquals(REQUEST_PATH, req.getPath()); - assertThat(req.getQuery(), containsString("basis=%28p%3FindAmy-payload%3A200%29")); + assertThat(req.getQuery(), containsString("basis=%28p%3Amy-payload%3A200%29")); } } } From d9dc5f069d4b93fb629136836499fa87a990dfc7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:21:41 -0800 Subject: [PATCH 43/48] Undo comment formatting. --- .../java/com/launchdarkly/sdk/server/FDv2DataSystem.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java index c07350c3..43c28eee 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java @@ -86,10 +86,10 @@ public Initializer build() { *

* This is a placeholder implementation. Not all dependencies are yet implemented. * - * @param logger the logger - * @param config the SDK configuration + * @param logger the logger + * @param config the SDK configuration * @param clientContext the client context - * @param logConfig the logging configuration + * @param logConfig the logging configuration * @return a new FDv2DataSystem instance * @throws UnsupportedOperationException since this is not yet fully implemented */ From 20ec764f3060a70671e92d8ef97faa808fce1ad6 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:44:04 -0800 Subject: [PATCH 44/48] Simplify builder interfaces. --- .../sdk/server/FDv2DataSource.java | 21 ++++----- .../sdk/server/FDv2DataSystem.java | 43 +++++-------------- .../integrations/DataSystemBuilder.java | 22 +++++----- .../FDv2PollingInitializerBuilder.java | 5 ++- .../FDv2PollingSynchronizerBuilder.java | 5 +-- .../FDv2StreamingSynchronizerBuilder.java | 5 +-- .../server/subsystems/DataSourceBuilder.java | 19 ++++++++ .../subsystems/DataSystemConfiguration.java | 12 +++--- .../server/subsystems/InitializerBuilder.java | 7 --- .../subsystems/SynchronizerBuilder.java | 7 --- 10 files changed, 61 insertions(+), 85 deletions(-) create mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuilder.java delete mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/InitializerBuilder.java delete mode 100644 lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/SynchronizerBuilder.java diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java index 835111ae..906c4847 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java @@ -6,7 +6,6 @@ import com.launchdarkly.sdk.server.datasources.Synchronizer; import com.launchdarkly.sdk.server.interfaces.DataSourceStatusProvider; import com.launchdarkly.sdk.server.subsystems.DataSource; -import com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSink; import com.launchdarkly.sdk.server.subsystems.DataSourceUpdateSinkV2; import java.io.Closeable; @@ -20,7 +19,7 @@ import java.util.stream.Collectors; class FDv2DataSource implements DataSource { - private final List initializers; + private final List> initializers; private final List synchronizers; private final DataSourceUpdateSinkV2 dataSourceUpdates; @@ -48,12 +47,12 @@ public enum State { Blocked } - private final SynchronizerFactory factory; + private final DataSourceFactory factory; private State state = State.Available; - public SynchronizerFactoryWithState(SynchronizerFactory factory) { + public SynchronizerFactoryWithState(DataSourceFactory factory) { this.factory = factory; } @@ -70,18 +69,14 @@ public Synchronizer build() { } } - public interface InitializerFactory { - Initializer build(); - } - - public interface SynchronizerFactory { - Synchronizer build(); + public interface DataSourceFactory { + T build(); } public FDv2DataSource( - ImmutableList initializers, - ImmutableList synchronizers, + ImmutableList> initializers, + ImmutableList> synchronizers, DataSourceUpdateSinkV2 dataSourceUpdates ) { this.initializers = initializers; @@ -120,7 +115,7 @@ private SynchronizerFactoryWithState getFirstAvailableSynchronizer() { private void runInitializers() { boolean anyDataReceived = false; - for (InitializerFactory factory : initializers) { + for (DataSourceFactory factory : initializers) { try { Initializer initializer = factory.build(); if (setActiveSource(initializer)) return; diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java index 43c28eee..519ae17b 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java @@ -13,11 +13,8 @@ import java.io.Closeable; import java.io.IOException; -import java.util.LinkedList; import java.util.concurrent.Future; -import static com.launchdarkly.sdk.server.ComponentsImpl.toHttpProperties; - /** * Internal implementation of the FDv2 data system. *

@@ -49,34 +46,18 @@ private FDv2DataSystem( this.readOnlyStore = new ReadonlyStoreFacade(store); } - private static class SynchronizerFactoryWrapper implements FDv2DataSource.SynchronizerFactory { - - private final SynchronizerBuilder builder; - private final DataSourceBuilderContext context; - - public SynchronizerFactoryWrapper(SynchronizerBuilder builder, DataSourceBuilderContext context) { - this.builder = builder; - this.context = context; - } - - @Override - public Synchronizer build() { - return builder.build(context); - } - } - - private static class InitializerFactoryWrapper implements FDv2DataSource.InitializerFactory { + private static class FactoryWrapper implements FDv2DataSource.DataSourceFactory { - private final InitializerBuilder builder; + private final DataSourceBuilder builder; private final DataSourceBuilderContext context; - public InitializerFactoryWrapper(InitializerBuilder builder, DataSourceBuilderContext context) { + public FactoryWrapper(DataSourceBuilder builder, DataSourceBuilderContext context) { this.builder = builder; this.context = context; } @Override - public Initializer build() { + public TDataSource build() { return builder.build(context); } } @@ -141,12 +122,12 @@ static FDv2DataSystem create( selectorSource ); - ImmutableList initializerFactories = dataSystemConfiguration.getInitializers().stream() - .map(initializer -> new InitializerFactoryWrapper(initializer, builderContext)) + ImmutableList> initializerFactories = dataSystemConfiguration.getInitializers().stream() + .map(initializer -> new FactoryWrapper<>(initializer, builderContext)) .collect(ImmutableList.toImmutableList()); - ImmutableList synchronizerFactories = dataSystemConfiguration.getSynchronizers().stream() - .map(synchronizer -> new SynchronizerFactoryWrapper(synchronizer, builderContext)) + ImmutableList> synchronizerFactories = dataSystemConfiguration.getSynchronizers().stream() + .map(synchronizer -> new FactoryWrapper<>(synchronizer, builderContext)) .collect(ImmutableList.toImmutableList()); DataSource dataSource = new FDv2DataSource( @@ -205,12 +186,8 @@ public void close() throws IOException { return; } try { - if (dataSource instanceof Closeable) { - ((Closeable) dataSource).close(); - } - if (store instanceof Closeable) { - ((Closeable) store).close(); - } + dataSource.close(); + store.close(); } finally { disposed = true; } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java index eb7eb9a6..13a4496a 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java @@ -17,22 +17,22 @@ */ public final class DataSystemBuilder { - private final List initializers = new ArrayList<>(); - private final List synchronizers = new ArrayList<>(); + private final List> initializers = new ArrayList<>(); + private final List> synchronizers = new ArrayList<>(); private ComponentConfigurer fDv1FallbackSynchronizer; private ComponentConfigurer persistentStore; private DataSystemConfiguration.DataStoreMode persistentDataStoreMode; /** * Add one or more initializers to the builder. - * To replace initializers, please refer to {@link #replaceInitializers(InitializerBuilder[])}. + * To replace initializers, please refer to {@link #replaceInitializers(DataSourceBuilder[])}. * * @param initializers the initializers to add * @return a reference to the builder */ @SafeVarargs - public final DataSystemBuilder initializers(InitializerBuilder... initializers) { - for (InitializerBuilder initializer : initializers) { + public final DataSystemBuilder initializers(DataSourceBuilder... initializers) { + for (DataSourceBuilder initializer : initializers) { this.initializers.add(initializer); } return this; @@ -46,9 +46,9 @@ public final DataSystemBuilder initializers(InitializerBuilder... initializers) * @return a reference to this builder */ @SafeVarargs - public final DataSystemBuilder replaceInitializers(InitializerBuilder... initializers) { + public final DataSystemBuilder replaceInitializers(DataSourceBuilder... initializers) { this.initializers.clear(); - for (InitializerBuilder initializer : initializers) { + for (DataSourceBuilder initializer : initializers) { this.initializers.add(initializer); } return this; @@ -62,8 +62,8 @@ public final DataSystemBuilder replaceInitializers(InitializerBuilder... initial * @return a reference to the builder */ @SafeVarargs - public final DataSystemBuilder synchronizers(SynchronizerBuilder... synchronizers) { - for (SynchronizerBuilder synchronizer : synchronizers) { + public final DataSystemBuilder synchronizers(DataSourceBuilder... synchronizers) { + for (DataSourceBuilder synchronizer : synchronizers) { this.synchronizers.add(synchronizer); } return this; @@ -77,9 +77,9 @@ public final DataSystemBuilder synchronizers(SynchronizerBuilder... synchronizer * @return a reference to this builder */ @SafeVarargs - public final DataSystemBuilder replaceSynchronizers(SynchronizerBuilder... synchronizers) { + public final DataSystemBuilder replaceSynchronizers(DataSourceBuilder... synchronizers) { this.synchronizers.clear(); - for (SynchronizerBuilder synchronizer : synchronizers) { + for (DataSourceBuilder synchronizer : synchronizers) { this.synchronizers.add(synchronizer); } return this; diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingInitializerBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingInitializerBuilder.java index 670d5afc..1dcc5f6a 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingInitializerBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingInitializerBuilder.java @@ -3,10 +3,11 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.internal.events.DiagnosticConfigProperty; import com.launchdarkly.sdk.server.StandardEndpoints; +import com.launchdarkly.sdk.server.datasources.Initializer; import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; import com.launchdarkly.sdk.server.subsystems.ClientContext; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuilder; import com.launchdarkly.sdk.server.subsystems.DiagnosticDescription; -import com.launchdarkly.sdk.server.subsystems.InitializerBuilder; /** @@ -28,7 +29,7 @@ * .fDv1FallbackSynchronizer(DataSystemComponents.fDv1Polling())); * */ -public abstract class FDv2PollingInitializerBuilder implements InitializerBuilder, DiagnosticDescription { +public abstract class FDv2PollingInitializerBuilder implements DataSourceBuilder, DiagnosticDescription { protected ServiceEndpoints serviceEndpointsOverride; /** diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingSynchronizerBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingSynchronizerBuilder.java index 70d08b0b..f3ec5219 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingSynchronizerBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2PollingSynchronizerBuilder.java @@ -6,9 +6,8 @@ import com.launchdarkly.sdk.server.datasources.Synchronizer; import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; import com.launchdarkly.sdk.server.subsystems.ClientContext; -import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuilder; import com.launchdarkly.sdk.server.subsystems.DiagnosticDescription; -import com.launchdarkly.sdk.server.subsystems.SynchronizerBuilder; import java.net.URI; import java.time.Duration; @@ -33,7 +32,7 @@ * .fDv1FallbackSynchronizer(DataSystemComponents.fDv1Polling())); * */ -public abstract class FDv2PollingSynchronizerBuilder implements SynchronizerBuilder, DiagnosticDescription { +public abstract class FDv2PollingSynchronizerBuilder implements DataSourceBuilder, DiagnosticDescription { /** * The default value for {@link #pollInterval(Duration)}: 30 seconds. */ diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingSynchronizerBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingSynchronizerBuilder.java index 129c8cd1..5464acf8 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingSynchronizerBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/FDv2StreamingSynchronizerBuilder.java @@ -6,9 +6,8 @@ import com.launchdarkly.sdk.server.datasources.Synchronizer; import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; import com.launchdarkly.sdk.server.subsystems.ClientContext; -import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuilder; import com.launchdarkly.sdk.server.subsystems.DiagnosticDescription; -import com.launchdarkly.sdk.server.subsystems.SynchronizerBuilder; import java.net.URI; import java.time.Duration; @@ -32,7 +31,7 @@ * .fDv1FallbackSynchronizer(DataSystemComponents.fDv1Polling())); * */ -public abstract class FDv2StreamingSynchronizerBuilder implements SynchronizerBuilder, DiagnosticDescription { +public abstract class FDv2StreamingSynchronizerBuilder implements DataSourceBuilder, DiagnosticDescription { /** * The default value for {@link #initialReconnectDelay(Duration)}: 1000 milliseconds. */ diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuilder.java new file mode 100644 index 00000000..9df5fe1a --- /dev/null +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuilder.java @@ -0,0 +1,19 @@ +package com.launchdarkly.sdk.server.subsystems; + + +/** + * Interface for building synchronizers and initializers. + *

+ * This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. + * It is in early access. If you want access to this feature, please join the EAP. https://launchdarkly.com/docs/sdk/features/data-saving-mode + * @param + */ +public interface DataSourceBuilder { + /** + * Builds a data source instance based on the provided context. + * + * @param context the context for building the data source + * @return the built data source instance + */ + TDataSource build(DataSourceBuilderContext context); +} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSystemConfiguration.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSystemConfiguration.java index be3ce4b0..271a819b 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSystemConfiguration.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSystemConfiguration.java @@ -37,8 +37,8 @@ public enum DataStoreMode { READ_WRITE } - private final ImmutableList initializers; - private final ImmutableList synchronizers; + private final ImmutableList> initializers; + private final ImmutableList> synchronizers; private final ComponentConfigurer fDv1FallbackSynchronizer; private final ComponentConfigurer persistentStore; private final DataStoreMode persistentDataStoreMode; @@ -56,8 +56,8 @@ public enum DataStoreMode { * @param persistentDataStoreMode see {@link #getPersistentDataStoreMode()} */ public DataSystemConfiguration( - ImmutableList initializers, - ImmutableList synchronizers, + ImmutableList> initializers, + ImmutableList> synchronizers, ComponentConfigurer fDv1FallbackSynchronizer, ComponentConfigurer persistentStore, DataStoreMode persistentDataStoreMode) { @@ -73,7 +73,7 @@ public DataSystemConfiguration( * * @return the list of initializer configurers */ - public ImmutableList getInitializers() { + public ImmutableList> getInitializers() { return initializers; } @@ -82,7 +82,7 @@ public ImmutableList getInitializers() { * * @return the list of synchronizer configurers */ - public ImmutableList getSynchronizers() { + public ImmutableList> getSynchronizers() { return synchronizers; } diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/InitializerBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/InitializerBuilder.java deleted file mode 100644 index 618f1046..00000000 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/InitializerBuilder.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.launchdarkly.sdk.server.subsystems; - -import com.launchdarkly.sdk.server.datasources.Initializer; - -public interface InitializerBuilder { - Initializer build(DataSourceBuilderContext context); -} diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/SynchronizerBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/SynchronizerBuilder.java deleted file mode 100644 index e18f9ce7..00000000 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/SynchronizerBuilder.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.launchdarkly.sdk.server.subsystems; - -import com.launchdarkly.sdk.server.datasources.Synchronizer; - -public interface SynchronizerBuilder { - Synchronizer build(DataSourceBuilderContext context); -} From 47e30b93592252d84f8f8fc89c12177762d8ba48 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:47:33 -0800 Subject: [PATCH 45/48] Individual imports. --- .../java/com/launchdarkly/sdk/server/FDv2DataSystem.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java index 519ae17b..ca115967 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java @@ -9,7 +9,12 @@ import com.launchdarkly.sdk.server.interfaces.DataStoreStatusProvider; import com.launchdarkly.sdk.server.interfaces.FlagChangeEvent; import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; -import com.launchdarkly.sdk.server.subsystems.*; +import com.launchdarkly.sdk.server.subsystems.DataSource; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuilder; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuilderContext; +import com.launchdarkly.sdk.server.subsystems.DataStore; +import com.launchdarkly.sdk.server.subsystems.LoggingConfiguration; +import com.launchdarkly.sdk.server.subsystems.DataSystemConfiguration; import java.io.Closeable; import java.io.IOException; From dd1146b4c53dffb5c1e2faa58cc5da3b1cb0d047 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:49:28 -0800 Subject: [PATCH 46/48] Remove todo --- .../java/com/launchdarkly/sdk/server/SelectorSourceFacade.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/SelectorSourceFacade.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/SelectorSourceFacade.java index 49b26bb7..6ce01af7 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/SelectorSourceFacade.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/SelectorSourceFacade.java @@ -4,8 +4,6 @@ import com.launchdarkly.sdk.server.datasources.SelectorSource; import com.launchdarkly.sdk.server.subsystems.TransactionalDataStore; -// TODO: Implement directly on store? - class SelectorSourceFacade implements SelectorSource { private final TransactionalDataStore store; public SelectorSourceFacade(TransactionalDataStore store) { From 38f90c2e979aee25b2e7d4b5cb13bb7f08dae87f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:20:11 -0800 Subject: [PATCH 47/48] Comment correction and minor code cleanup. --- .../integrations/DataSystemBuilder.java | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java index 13a4496a..ccf018c6 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/integrations/DataSystemBuilder.java @@ -6,6 +6,7 @@ import com.launchdarkly.sdk.server.subsystems.*; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; /** @@ -32,15 +33,13 @@ public final class DataSystemBuilder { */ @SafeVarargs public final DataSystemBuilder initializers(DataSourceBuilder... initializers) { - for (DataSourceBuilder initializer : initializers) { - this.initializers.add(initializer); - } + this.initializers.addAll(Arrays.asList(initializers)); return this; } /** * Replaces any existing initializers with the given initializers. - * To add initializers, please refer to {@link #initializers(InitializerBuilder[])}. + * To add initializers, please refer to {@link #initializers(DataSourceBuilder[])}. * * @param initializers the initializers to replace the current initializers with * @return a reference to this builder @@ -48,30 +47,26 @@ public final DataSystemBuilder initializers(DataSourceBuilder... in @SafeVarargs public final DataSystemBuilder replaceInitializers(DataSourceBuilder... initializers) { this.initializers.clear(); - for (DataSourceBuilder initializer : initializers) { - this.initializers.add(initializer); - } + this.initializers.addAll(Arrays.asList(initializers)); return this; } /** * Add one or more synchronizers to the builder. - * To replace synchronizers, please refer to {@link #replaceSynchronizers(SynchronizerBuilder[])}. + * To replace synchronizers, please refer to {@link #replaceSynchronizers(DataSourceBuilder[])}. * * @param synchronizers the synchronizers to add * @return a reference to the builder */ @SafeVarargs public final DataSystemBuilder synchronizers(DataSourceBuilder... synchronizers) { - for (DataSourceBuilder synchronizer : synchronizers) { - this.synchronizers.add(synchronizer); - } + this.synchronizers.addAll(Arrays.asList(synchronizers)); return this; } /** * Replaces any existing synchronizers with the given synchronizers. - * To add synchronizers, please refer to {@link #synchronizers(SynchronizerBuilder[])}. + * To add synchronizers, please refer to {@link #synchronizers(DataSourceBuilder[])}. * * @param synchronizers the synchronizers to replace the current synchronizers with * @return a reference to this builder @@ -79,9 +74,7 @@ public final DataSystemBuilder synchronizers(DataSourceBuilder... @SafeVarargs public final DataSystemBuilder replaceSynchronizers(DataSourceBuilder... synchronizers) { this.synchronizers.clear(); - for (DataSourceBuilder synchronizer : synchronizers) { - this.synchronizers.add(synchronizer); - } + this.synchronizers.addAll(Arrays.asList(synchronizers)); return this; } From ee8207755edfd57b687b967a0b35e22d87f6c3f7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:57:23 -0800 Subject: [PATCH 48/48] Rename DataSourceBuildContext to DataSourceBuildInputs. --- .../com/launchdarkly/sdk/server/DataSystemComponents.java | 8 ++++---- .../java/com/launchdarkly/sdk/server/FDv2DataSystem.java | 8 ++++---- ...urceBuilderContext.java => DataSourceBuildInputs.java} | 6 +++--- .../sdk/server/subsystems/DataSourceBuilder.java | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) rename lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/{DataSourceBuilderContext.java => DataSourceBuildInputs.java} (95%) diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystemComponents.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystemComponents.java index 919a8fdc..d9e01f31 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystemComponents.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/DataSystemComponents.java @@ -7,7 +7,7 @@ import com.launchdarkly.sdk.server.integrations.FDv2StreamingSynchronizerBuilder; import com.launchdarkly.sdk.server.integrations.PollingDataSourceBuilder; import com.launchdarkly.sdk.server.interfaces.ServiceEndpoints; -import com.launchdarkly.sdk.server.subsystems.DataSourceBuilderContext; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuildInputs; import java.net.URI; @@ -24,7 +24,7 @@ public final class DataSystemComponents { static class FDv2PollingInitializerBuilderImpl extends FDv2PollingInitializerBuilder { @Override - public Initializer build(DataSourceBuilderContext context) { + public Initializer build(DataSourceBuildInputs context) { ServiceEndpoints endpoints = serviceEndpointsOverride != null ? serviceEndpointsOverride : context.getServiceEndpoints(); @@ -50,7 +50,7 @@ public Initializer build(DataSourceBuilderContext context) { static class FDv2PollingSynchronizerBuilderImpl extends FDv2PollingSynchronizerBuilder { @Override - public Synchronizer build(DataSourceBuilderContext context) { + public Synchronizer build(DataSourceBuildInputs context) { ServiceEndpoints endpoints = serviceEndpointsOverride != null ? serviceEndpointsOverride : context.getServiceEndpoints(); @@ -78,7 +78,7 @@ public Synchronizer build(DataSourceBuilderContext context) { static class FDv2StreamingSynchronizerBuilderImpl extends FDv2StreamingSynchronizerBuilder { @Override - public Synchronizer build(DataSourceBuilderContext context) { + public Synchronizer build(DataSourceBuildInputs context) { ServiceEndpoints endpoints = serviceEndpointsOverride != null ? serviceEndpointsOverride : context.getServiceEndpoints(); diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java index ca115967..70fc0048 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSystem.java @@ -11,7 +11,7 @@ import com.launchdarkly.sdk.server.interfaces.FlagChangeListener; import com.launchdarkly.sdk.server.subsystems.DataSource; import com.launchdarkly.sdk.server.subsystems.DataSourceBuilder; -import com.launchdarkly.sdk.server.subsystems.DataSourceBuilderContext; +import com.launchdarkly.sdk.server.subsystems.DataSourceBuildInputs; import com.launchdarkly.sdk.server.subsystems.DataStore; import com.launchdarkly.sdk.server.subsystems.LoggingConfiguration; import com.launchdarkly.sdk.server.subsystems.DataSystemConfiguration; @@ -54,9 +54,9 @@ private FDv2DataSystem( private static class FactoryWrapper implements FDv2DataSource.DataSourceFactory { private final DataSourceBuilder builder; - private final DataSourceBuilderContext context; + private final DataSourceBuildInputs context; - public FactoryWrapper(DataSourceBuilder builder, DataSourceBuilderContext context) { + public FactoryWrapper(DataSourceBuilder builder, DataSourceBuildInputs context) { this.builder = builder; this.context = context; } @@ -116,7 +116,7 @@ static FDv2DataSystem create( DataSystemConfiguration dataSystemConfiguration = config.dataSystem.build(); SelectorSource selectorSource = new SelectorSourceFacade(store); - DataSourceBuilderContext builderContext = new DataSourceBuilderContext( + DataSourceBuildInputs builderContext = new DataSourceBuildInputs( clientContext.getBaseLogger(), clientContext.getThreadPriority(), dataSourceUpdates, diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuilderContext.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuildInputs.java similarity index 95% rename from lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuilderContext.java rename to lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuildInputs.java index eda63802..ac2d4dac 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuilderContext.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuildInputs.java @@ -8,7 +8,7 @@ import java.util.concurrent.ScheduledExecutorService; /** - * Context information provided to initializer and synchronizer builders. + * Build information (dependencies and configuration) provided to initializer and synchronizer builders. *

* This class is not stable, and not subject to any backwards compatibility guarantees or semantic versioning. * It is in early access. If you want access to this feature, please join the EAP. https://launchdarkly.com/docs/sdk/features/data-saving-mode @@ -16,7 +16,7 @@ * This consolidates all the parameters needed to construct data source components, * including HTTP configuration, logging, scheduling, and selector state. */ -public final class DataSourceBuilderContext { +public final class DataSourceBuildInputs { private final LDLogger baseLogger; private final int threadPriority; private final DataSourceUpdateSink dataSourceUpdates; @@ -38,7 +38,7 @@ public final class DataSourceBuilderContext { * @param diagnosticStore diagnostic data accumulator (may be null) * @param selectorSource source for obtaining selectors */ - public DataSourceBuilderContext( + public DataSourceBuildInputs( LDLogger baseLogger, int threadPriority, DataSourceUpdateSink dataSourceUpdates, diff --git a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuilder.java b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuilder.java index 9df5fe1a..70fdeeea 100644 --- a/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuilder.java +++ b/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/subsystems/DataSourceBuilder.java @@ -15,5 +15,5 @@ public interface DataSourceBuilder { * @param context the context for building the data source * @return the built data source instance */ - TDataSource build(DataSourceBuilderContext context); + TDataSource build(DataSourceBuildInputs context); }