From 19d9c02d50f2a386e561ae62b6885195655a9233 Mon Sep 17 00:00:00 2001 From: Todd Anderson Date: Mon, 12 Jan 2026 11:47:17 -0500 Subject: [PATCH 01/24] 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 b39928b..3b4497f 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 0000000..7959d41 --- /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 0000000..21639de --- /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 0000000..98fd071 --- /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 0000000..01c5272 --- /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 0000000..21e252d --- /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 0000000..916b80a --- /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 0000000..80cd3ab --- /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 0000000..897a7fc --- /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 0000000..15fafdb --- /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 0000000..b89aed2 --- /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 0000000..c7ee8c7 --- /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 0000000..79f83a3 --- /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 0000000..db6f785 --- /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 0000000..ae32c71 --- /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/24] 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 db6f785..ae64534 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 0000000..94d2ff1 --- /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 0000000..054730f --- /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/24] 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 98fd071..37254a0 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 0000000..faf1f9a --- /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 15fafdb..8ffc69e 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 c7ee8c7..5bdd7fc 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 0000000..09d584d --- /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/24] 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 85a5238..c92affa 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 0000000..b5377cf --- /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 0000000..332ca85 --- /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 0000000..f972f39 --- /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/24] 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 c92affa..85a5238 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/24] 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 867d0e8..99cc057 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 85a5238..c92affa 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 0000000..286b384 --- /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 0000000..e4d23a9 --- /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 0000000..eb0ca5e --- /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 0000000..08bd0a8 --- /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 0000000..b474854 --- /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 0000000..d37488d --- /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 f972f39..94d2767 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/24] 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 286b384..c6d87e0 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 e4d23a9..8e5c9df 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 0000000..7e63d0b --- /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 0000000..3040aee --- /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 0000000..b4871f7 --- /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 b5377cf..6d11f97 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 0000000..c950ca7 --- /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 eb0ca5e..0000000 --- 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 08bd0a8..0000000 --- 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 b474854..0000000 --- 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 0000000..937ecb9 --- /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 94d2767..b0669e4 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 0000000..ece5caa --- /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 0000000..c908e51 --- /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/24] 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 a6ce723..3485fe8 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/24] 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 6d11f97..1572e7a 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 0000000..8a2b2d3 --- /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 0000000..8e526ac --- /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/24] 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 b4871f7..1c08f12 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/24] 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 8a2b2d3..0000000 --- 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/24] 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 1c08f12..abb6dbb 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 0000000..116132e --- /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 8e526ac..a4e1add 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/24] 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 332ca85..b2c84de 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 b0669e4..c386b8f 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/24] 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 99cc057..464e94b 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/24] 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 0000000..e3ff539 --- /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 0000000..1ba5d45 --- /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 7e63d0b..f871791 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 0000000..63829b1 --- /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 1572e7a..3f7ad16 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 b2c84de..5c3bdc5 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 c386b8f..40f960a 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 0000000..522e94f --- /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 116132e..dd8c38a 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/24] 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 1ba5d45..3a489d9 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 c950ca7..2212354 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 abb6dbb..e1b0eae 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 d37488d..0000000 --- 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/24] 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 c92affa..85a5238 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/24] 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 937ecb9..163c384 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/24] 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 7258346..a6ce723 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/24] 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 a6ce723..a27e9c7 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/24] 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 5c3bdc5..bf44a30 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 40f960a..ee86f23 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 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 22/24] 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 c6d87e0..51fcf93 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 3a489d9..adbd124 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 8e5c9df..8a2297e 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 c908e51..ea3a77a 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 dd8c38a..42e4b4b 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 a4e1add..ce8fa6f 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 23/24] 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 adbd124..2e124a4 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 24/24] 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 51fcf93..6cb391d 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);