Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
19d9c02
chore: adds fdv2 payload parsing and protocol handling
tanderson-ld Jan 12, 2026
fbea872
adding package info files and fixing package name issue
tanderson-ld Jan 12, 2026
adcaa0e
more checkstyle fixes
tanderson-ld Jan 12, 2026
f2b209d
chore: Add interfaces for synchronizer/initializer.
kinyoklion Jan 13, 2026
8c115cc
Merge branch 'main' into rlamb/add-fdv2-data-source-interfaces
kinyoklion Jan 13, 2026
de2fded
Revert version change
kinyoklion Jan 13, 2026
98d3b39
feat: Add FDv2 polling support.
kinyoklion Jan 13, 2026
da3c639
Merge remote-tracking branch 'origin' into rlamb/add-fdv2-data-source…
kinyoklion Jan 13, 2026
8fb88ed
WIP: Polling initializer/synchronizer.
kinyoklion Jan 14, 2026
da27015
Use updated internal lib.
kinyoklion Jan 14, 2026
aba46ef
Update comment
kinyoklion Jan 14, 2026
7401331
Add termination.
kinyoklion Jan 14, 2026
bba0cdc
Remove test file that isn't ready.
kinyoklion Jan 14, 2026
89bd017
Polling tests and some fixes.
kinyoklion Jan 14, 2026
228f3e6
Try pre block.
kinyoklion Jan 14, 2026
9469b23
Add streaming path.
kinyoklion Jan 14, 2026
9a450e6
Merge branch 'main' of github.com:launchdarkly/java-core into rlamb/a…
kinyoklion Jan 14, 2026
4b8313b
Use the DataStoreTypes.ChangeSet type for data source results.
kinyoklion Jan 14, 2026
31eb13e
Make iterable async queue package private.
kinyoklion Jan 14, 2026
4a2fe3b
Revert Version.java
kinyoklion Jan 14, 2026
3428591
Add comments to SelectorSource.
kinyoklion Jan 14, 2026
ff60216
Revert build.gradle.
kinyoklion Jan 14, 2026
e985f80
Update launchdarklyJavaSdkInternal version to 1.6.1
kinyoklion Jan 14, 2026
a956484
Move mermaid out of doc comment.
kinyoklion Jan 14, 2026
ff2376e
Merge branch 'rlamb/add-fdv2-data-source-interfaces' of github.com:la…
kinyoklion Jan 14, 2026
194c30c
PR feedback.
kinyoklion Jan 14, 2026
707fe0e
Implement more shutdown logic.
kinyoklion Jan 14, 2026
cb79f5e
Change null check.
kinyoklion Jan 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some usage of "poll / polling" in this file, not sure if you want to eliminate that so if this requestor is used elsewhere, the concept of polling doesn't come with.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will change the types in the interface level, but this implementation is specific to our polling implementation and the format of data it provides as well as the cycle it operates on.

Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
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.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.Closeable;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;

/**
* Implementation of FDv2Requestor for polling feature flag data via FDv2 protocol.
*/
public class DefaultFDv2Requestor implements FDv2Requestor, Closeable {
private static final String VERSION_QUERY_PARAM = "version";
private static final String STATE_QUERY_PARAM = "state";

private final OkHttpClient httpClient;
private final URI pollingUri;
private final Headers headers;
private final LDLogger logger;
private final Map<URI, String> 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<FDv2PayloadResponse> Poll(Selector selector) {
CompletableFuture<FDv2PayloadResponse> future = new CompletableFuture<>();

try {
// Build the request URI with query parameters
URI requestUri = pollingUri;

if (!selector.isEmpty()) {
requestUri = HttpHelpers.addQueryParam(requestUri, VERSION_QUERY_PARAM, String.valueOf(selector.getVersion()));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see this version constraint on the Dotnet impl. I'm wondering why this ever was imposed? What if 0 is valid?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a couple reasons. 0 and not present would be the same, and 0 is the "zero" value for the go implementation.

That said this should use !selector.isEmpty() and I will change it.


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(null);
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);
}
}

// 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<FDv2Event> events = FDv2Event.parseEventsArray(responseBody);

// Create and return the response
FDv2PayloadResponse pollingResponse = new FDv2PayloadResponse(events, response.headers());
future.complete(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);
}
}
Original file line number Diff line number Diff line change
@@ -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<ItemDescriptor> 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<DataKind, List<Map.Entry<String, ItemDescriptor>>> 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<Map.Entry<String, ItemDescriptor>> itemsList =
kindToItems.computeIfAbsent(dataKind, k -> new ArrayList<>());

itemsList.add(new AbstractMap.SimpleImmutableEntry<>(change.getKey(), item));
}

ImmutableList.Builder<Map.Entry<DataKind, KeyedItems<ItemDescriptor>>> dataBuilder =
ImmutableList.builder();

for (Map.Entry<DataKind, List<Map.Entry<String, ItemDescriptor>>> 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;
}
}
}
Loading