Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5504b2e
feat: Park FDv2 synchronizers after unexpected failures
beekld Oct 29, 2026
e731444
refactor: Rename retryReset to resetThresholdMet
beekld Nov 3, 2026
017eba3
feat: Reset park counts and add unpark conditions
beekld Nov 4, 2026
987fd44
feat: Keep the current synchronizer when no other synchronizer is ava…
beekld Nov 5, 2026
99e9e0f
test: Cover unpark conditions and FDv1 fallback parking
beekld Nov 5, 2026
42f0a23
feat: End and extend the FDv1 fallback after its TTL
beekld Nov 5, 2026
4accf45
refactor: Replace synchronizer conditions with deadlines checked on e…
beekld Nov 5, 2026
1c8dd63
refactor: Remove the unused executor from FDv2DataSource
beekld Nov 6, 2026
80e3b09
test: Stop the adapter test from holding a common-pool thread
beekld Nov 6, 2026
465970a
fix: Report INTERRUPTED while waiting for a synchronizer to unpark
beekld Nov 6, 2026
bc32ee3
fix: Report the reset threshold from the FDv1 fallback adapter
beekld Nov 6, 2026
d81a646
fix: Report a terminal error when the stream thread ends unexpectedly
beekld Nov 6, 2026
d62ab41
test: Remove condition tests that assert nothing
beekld Nov 6, 2026
a700bff
fix: Wait for the data source to stop before reporting OFF on close
beekld Nov 10, 2026
37cda43
fix: Keep synchronizing when a synchronizer throws from close
beekld Nov 10, 2026
cb44a7a
fix: Restart the streaming reset measurement on failures that keep th…
beekld Nov 10, 2026
00f4bd6
fix: Report an error when a synchronizer shuts down by itself
beekld Nov 11, 2026
929ae4a
docs: Use the spec's terms for full payloads and bases in comments
beekld Nov 11, 2026
6e7c4c8
fix: Stop the stream when it connects again after close
beekld Nov 11, 2026
99840d0
refactor: State the recovery condition directly
beekld Nov 11, 2026
94f7478
fix: Say the configured synchronizers instead of FDv2 in fallback logs
beekld Nov 11, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.launchdarkly.sdk.server;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.concurrent.CompletableFuture;

/**
* A latch that is released once, and that callers wait on through futures.
* <p>
* This exists so that a loop can wait for "a result or the latch, whichever comes first" on every iteration without
* a memory leak. The obvious way to write that wait is {@code CompletableFuture.anyOf(result, latchFuture)}. But
* {@code anyOf} attaches a completion node to each future it is given, and a pending {@link CompletableFuture} keeps
* those nodes until it completes. If the latch used one shared future, every iteration that ended with a result would
* leave one more node on that future. The latch can stay pending for the life of the process, so the nodes would
* accumulate without limit.
* <p>
* To prevent this, {@link #newFuture()} returns a new future for each wait. The latch holds these futures weakly, so a
* future that nobody waits on any more is garbage collected, together with the nodes that {@code anyOf} attached to
* it. When the latch is released, it completes each future that is still waiting. After the release,
* {@link #newFuture()} returns a future that is already complete.
*/
final class AsyncLatch {
private final Object lock = new Object();

// The futures that wait for the release, or null after the release.
private Set<CompletableFuture<Void>> pending = Collections.newSetFromMap(new WeakHashMap<>());

/**
* A new future that completes when the latch is released.
*
* @return the future, which is already complete if the latch has been released
*/
CompletableFuture<Void> newFuture() {
CompletableFuture<Void> future = new CompletableFuture<>();
synchronized (lock) {
if (pending != null) {
pending.add(future);
return future;
}
}
future.complete(null);
return future;
}

/**
* Releases the latch, which completes every future from {@link #newFuture()}. Releasing it again has no effect.
*/
void release() {
List<CompletableFuture<Void>> waiting;
synchronized (lock) {
if (pending == null) {
return;
}
// The list holds the futures strongly, so none is garbage collected before it completes.
waiting = new ArrayList<>(pending);
pending = null;
}
for (CompletableFuture<Void> future : waiting) {
future.complete(null);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.launchdarkly.sdk.server;

import com.launchdarkly.sdk.internal.collections.IterableAsyncQueue;
import com.launchdarkly.sdk.internal.http.FailureClass;
import com.launchdarkly.sdk.internal.http.HttpErrors;
import com.launchdarkly.sdk.fdv2.Selector;
import com.launchdarkly.sdk.server.datasources.FDv2SourceResult;
import com.launchdarkly.sdk.server.datasources.Synchronizer;
Expand Down Expand Up @@ -130,9 +132,14 @@ public void close() {
* This sink doesn't delegate to any other sink - it exists solely to convert FDv1 updates to FDv2 results.
*/
private static class ConvertingUpdateSink implements DataSourceUpdateSink {
// The number of VALID statuses in a row that meet the reset threshold, the same as for a polling synchronizer.
private static final int RESET_THRESHOLD_SUCCESSFUL_RESULTS = 2;

private final IterableAsyncQueue<FDv2SourceResult> resultQueue;
// Only a full data set carries an environment ID; it is retained for subsequent partial updates.
private volatile String environmentId = null;
// The number of VALID statuses the data source has reported since its last failure.
private int consecutiveSuccessfulResults = 0;

public ConvertingUpdateSink(IterableAsyncQueue<FDv2SourceResult> resultQueue) {
this.resultQueue = resultQueue;
Expand Down Expand Up @@ -189,16 +196,39 @@ public void updateStatus(DataSourceStatusProvider.State newState, DataSourceStat
// Convert state changes to FDv2SourceResult status events
switch (newState) {
case INTERRUPTED:
resultQueue.put(FDv2SourceResult.interrupted(newError, false));
consecutiveSuccessfulResults = 0;
if (newError != null
&& newError.getKind() == DataSourceStatusProvider.ErrorKind.ERROR_RESPONSE
&& HttpErrors.classifyHttpFailure(newError.getStatusCode()) == FailureClass.UNEXPECTED) {
resultQueue.put(FDv2SourceResult.terminalError(newError, false));
} else {
resultQueue.put(FDv2SourceResult.interrupted(newError, false));
}
break;
case OFF:
consecutiveSuccessfulResults = 0;
if (newError != null) {
resultQueue.put(FDv2SourceResult.terminalError(newError, false));
} else {
resultQueue.put(FDv2SourceResult.shutdown());
}
break;
case VALID:
consecutiveSuccessfulResults++;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think you can conclude success from consecutive valid status.

StreamProcessor calls updateStatus(State.VALID, null) at the end of handleMessage for every message event, including the default: branch that logs "Unexpected event found in stream" (StreamProcessor.java:327-331). Two unknown event types in a row (the forward-compatibility case: any new server event name) produce two VALIDs with zero data delivered.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, this code kind of assumes that the FDv1 fallback is a polling synchronizer. Making this work for any kind of synchronizer would require adding a way to signal the reset from any kind of FDv1 DataSource, and I don't think that's worth the effort, given FDv1 isn't the future anyway.

if (consecutiveSuccessfulResults == RESET_THRESHOLD_SUCCESSFUL_RESULTS) {
// Any data that came with this status is already queued, so report the reset with no changes.
ChangeSet<Iterable<Map.Entry<DataKind, KeyedItems<ItemDescriptor>>>> noChanges =
new ChangeSet<>(
ChangeSetType.None,
Selector.EMPTY,
Collections.emptyList(),
environmentId,
/* shouldPersist= */ true);
resultQueue.put(FDv2SourceResult.changeSet(noChanges, false).withResetThresholdMet());
}
break;
case INITIALIZING:
// These states don't map to FDv2SourceResult status events
// This state doesn't map to an FDv2SourceResult status event
break;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,10 @@ public Synchronizer build(DataSourceBuildInputs context) {
StandardEndpoints.FDV2_STREAMING_REQUEST_PATH,
context.getBaseLogger(),
context.getSelectorSource(),
context.getSharedExecutor(),
payloadFilter,
initialReconnectDelay,
StreamingSynchronizerImpl.RESET_THRESHOLD,
context.getThreadPriority(),
context.getDiagnosticStore()
);
Expand Down
Loading
Loading