Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
0c98c92
feat(events): add a bounded flush (tier 2)
abelonogov-ld Sep 4, 2026
d57313c
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 15, 2026
818d239
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 15, 2026
63ca732
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 15, 2026
d399f54
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 17, 2026
ef886dc
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 18, 2026
536a453
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 21, 2026
19a14b3
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 21, 2026
f233167
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 21, 2026
0bcef8b
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 21, 2026
644ac0f
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 21, 2026
68d8aa9
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 22, 2026
7bd8b07
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 22, 2026
7385975
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 22, 2026
f278820
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 22, 2026
c6386f7
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 22, 2026
f36aee4
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 22, 2026
23bba70
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 22, 2026
a408e75
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 22, 2026
113ee34
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 22, 2026
70babb2
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 22, 2026
ff5aeec
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 22, 2026
105c870
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 22, 2026
bdfd13a
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 22, 2026
6290c38
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 22, 2026
ee78359
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 23, 2026
c629046
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 23, 2026
2237489
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 23, 2026
df9e409
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 23, 2026
e113694
Report a failed flushAndWait from a closed client
abelonogov-ld Sep 23, 2026
4ac8572
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 23, 2026
7fe25ed
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Sep 28, 2026
6f1ae80
Flush through a future, shared by the callers who asked at once
abelonogov-ld Oct 2, 2026
3ab2ffc
Merge branch 'andrey/event-durability-tier1-buffer' into andrey/event…
abelonogov-ld Oct 2, 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
Expand Up @@ -26,6 +26,7 @@
import org.junit.Test;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

import okhttp3.HttpUrl;
import okhttp3.mockwebserver.MockResponse;
Expand Down Expand Up @@ -95,6 +96,36 @@ public void testTrackData() throws IOException, InterruptedException {
}
}

@Test
public void flushAndWaitReportsDelivery() throws IOException, InterruptedException {
try (MockWebServer mockEventsServer = new MockWebServer()) {
mockEventsServer.start();
mockEventsServer.enqueue(new MockResponse());

LDConfig ldConfig = baseConfigBuilder(mockEventsServer).build();
try (LDClient client = LDClient.init(application, ldConfig, ldContext, 0)) {
client.track("test-event");

assertTrue(client.flushAndWait(5, TimeUnit.SECONDS));
LDValue[] events = getEventsFromLastRequest(mockEventsServer, 2);
assertCustomEvent(events[1], ldContext, "test-event");
}
}
}

@Test
public void flushAndWaitReportsFailureOnceClosed() throws IOException {
try (MockWebServer mockEventsServer = new MockWebServer()) {
mockEventsServer.start();

LDConfig ldConfig = baseConfigBuilder(mockEventsServer).build();
LDClient client = LDClient.init(application, ldConfig, ldContext, 0);
client.close();

assertFalse(client.flushAndWait(5, TimeUnit.SECONDS));
}
}

@Test
public void testTrackDataValueNull() throws IOException, InterruptedException {
try (MockWebServer mockEventsServer = new MockWebServer()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Future;

/**
* This class contains the package-private implementations of component factories and builders whose
Expand Down Expand Up @@ -72,6 +73,12 @@ public void flush() {}
@Override
public void blockingFlush() {}

@Override
public Future<Boolean> flushAsync() {
// Nothing was recorded, so there is nothing undelivered to warn the caller about.
return new LDSuccessFuture<>(true);
}

@Override
public void setInBackground(boolean inBackground) {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,19 @@ final class DirectEventProcessor implements EventProcessor {
/** Set under {@link #submitLock} once close() has queued the release of the sender. */
private boolean shuttingDown = false;

/**
* Guards {@link #pendingFlush}. Taken on a caller's thread and on the delivery thread, never
* while holding {@link #recordLock}, and nothing blocking happens under it.
*/
private final Object flushLock = new Object();

/**
* The delivery that is queued but has not started, which a flush request arriving now can wait
* on instead of queueing another. Null while nothing is queued, and cleared again as the queued
* delivery begins, which is the point past which it can no longer speak for what is recorded.
*/
private LDAwaitFuture<Boolean> pendingFlush;

DirectEventProcessor(
OutboundEventBuffer buffer,
EventSender eventSender,
Expand Down Expand Up @@ -331,21 +344,12 @@ public void setOffline(boolean offline) {

@Override
public void flush() {
if (isStopped()) {
return;
}
submit(this::deliverPayload);
flushAsync();
}

@Override
public void blockingFlush() {
if (isStopped()) {
return;
}
Future<?> delivery = submit(this::deliverPayload);
if (delivery == null) {
return;
}
Future<Boolean> delivery = flushAsync();
try {
delivery.get();
} catch (InterruptedException e) {
Expand All @@ -355,6 +359,61 @@ public void blockingFlush() {
}
}

@Override
public Future<Boolean> flushAsync() {
if (isStopped()) {
return new LDSuccessFuture<>(false);
}
return queueDelivery();
}

/**
* Queues a delivery, or hands back one that is already queued and has not started.
* <p>
* A delivery that has not started yet will take everything recorded up to the moment it does,
* which includes whatever the caller recorded before asking, so waiting on it answers the
* caller's question as well as a delivery of its own would. Without this, flushes arriving
* faster than a post completes each queue their own, and the one that matters -- the
* {@code flushAndWait} at shutdown -- waits behind all of them.
*/
private Future<Boolean> queueDelivery() {
synchronized (flushLock) {
if (pendingFlush != null) {
return pendingFlush;
}
LDAwaitFuture<Boolean> result = new LDAwaitFuture<>();
if (submit(() -> runDelivery(result)) == null) {
// Shutting down, so there is no thread left to deliver on and nothing will be sent.
return new LDSuccessFuture<>(false);
}
pendingFlush = result;
return result;
}
}

/**
* Runs one delivery on behalf of every flush request that joined it, and tells them all how it
* went.
*/
private void runDelivery(LDAwaitFuture<Boolean> result) {
synchronized (flushLock) {
// Requests arriving from here on need a delivery of their own: this one is about to take
// the buffer, and what it takes is all it can speak for.
if (pendingFlush == result) {
pendingFlush = null;
}
}
boolean delivered = false;
try {
delivered = deliverPayloadReportingOutcome();
} catch (Throwable t) {
// Caught here rather than left to guarded(), because a caller is waiting on the future
// and completing it matters more than the stack reaching the executor.
logUnexpectedError(t);
}
result.set(delivered);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Flush reports success after failed send

Medium Severity

flushAndWait can return true after the caller's events were already taken by an in-progress or just-queued delivery that then failed. Coalescing only joins a delivery that has not started, so a later flushAsync waits on a follow-up. That follow-up finds an empty buffer and treats it as success, even though the earlier send lost the events. A crash handler can then treat those events as delivered when they are gone.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3ab2ffc. Configure here.


@Override
public void close() throws IOException {
if (!closed.compareAndSet(false, true)) {
Expand All @@ -368,22 +427,23 @@ public void close() throws IOException {
// once the processor is gone. While offline that chance is not taken, and whatever is held
// is discarded. Offline is the application telling the SDK to stay off the network, and
// shutting down does not revoke that.
Future<?> delivery = submit(this::deliverPayload);
if (delivery != null) {
try {
delivery.get(closeBudgetMillis, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
// Deliberately not cancelled. The run has already been drained into a payload, so
// interrupting now would make the loss certain, while leaving it to run costs
// nothing: the scheduler thread is a daemon, and returning from close() does not
// end an Android process. The budget bounds the caller, not the delivery.
logger.warn("Gave up waiting for the final event delivery after {}ms;" +
" it continues in the background", closeBudgetMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
logUnexpectedError(e.getCause() == null ? e : e.getCause());
}
//
// Queued directly rather than through flushAsync(), which refuses once closed is set, but
// through the same coalescing: a delivery that has not started yet will take these events
// too, so there is no reason to queue a second one behind it.
try {
queueDelivery().get(closeBudgetMillis, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
// Deliberately not cancelled. The run has already been drained into a payload, so
// interrupting now would make the loss certain, while leaving it to run costs
// nothing: the scheduler thread is a daemon, and returning from close() does not
// end an Android process. The budget bounds the caller, not the delivery.
logger.warn("Gave up waiting for the final event delivery after {}ms;" +
" it continues in the background", closeBudgetMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
logUnexpectedError(e.getCause() == null ? e : e.getCause());
}
// Queued on both of the threads that post through the sender, so that it is released by
// whichever of them finishes last. Closing it here instead would pull the HTTP client out
Expand Down Expand Up @@ -425,17 +485,29 @@ private void releaseSenderWhenLast() {
}

/**
* Serializes and sends everything buffered. Runs on the scheduler thread, which is
* single-threaded, so only one payload is ever in flight and the run is taken exactly once per
* delivery.
* <p>
* The run and the counters are taken together under {@link #recordLock}, so an evaluation is
* never split across two payloads, and encoded outside it, so recording does not wait on the
* encoder.
* Serializes and sends everything buffered, for the periodic flush, which has nobody waiting to
* find out how it went. It is a fixed-delay series, so a run is only ever scheduled once the one
* before it has finished and these cannot pile up the way requested flushes could.
*/
private void deliverPayload() {
deliverPayloadReportingOutcome();
}

/**
* Delivers as {@link #deliverPayload()} does, and says whether it worked, for the callers of a
* requested flush, who are waiting to find out.
* <p>
* Runs on the scheduler thread, which is single-threaded, so only one payload is ever in flight
* and the run is taken exactly once per delivery. The run and the counters are taken together
* under {@link #recordLock}, so an evaluation is never split across two payloads, and encoded
* outside it, so recording does not wait on the encoder.
*
* @return true if the events reached the service, or if there were none to send; false if they
* could not be sent or the service did not accept them
*/
private boolean deliverPayloadReportingOutcome() {
if (disabled || offline.get()) {
return;
return false;
}
List<Event> run;
List<EventSummarizer.EventSummary> summaries;
Expand All @@ -450,19 +522,22 @@ private void deliverPayload() {
payload = buffer.encode(run, summaries);
} catch (IOException e) {
logUnexpectedError(e);
return;
return false;
}
if (payload == null) {
return;
return true;
}
if (diagnosticStore != null) {
diagnosticStore.recordEventsInBatch(payload.getEventCount());
}
try {
handleResponse(eventSender.sendAnalyticsEvents(payload.getData(),
payload.getEventCount(), eventsUri));
EventSender.Result result = eventSender.sendAnalyticsEvents(payload.getData(),
payload.getEventCount(), eventsUri);
handleResponse(result);
return result != null && result.isSuccess();

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.

This is the core new branch — false from a non-2xx — and it has no test. HttpServer.start(Handlers.status(503)) (recoverable, exercises the sender's retry) and Handlers.status(401) (the mustShutDown path, pattern already at beingToldToShutDownStopsRecordingAndDelivery) make both cases cheap. Worth covering, since everything else in this PR is about what this value means.

} catch (Exception e) {
logUnexpectedError(e);
return false;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,49 @@ private void flushInternal() {
eventProcessor.flush();
}

@Override
public boolean flushAndWait(long timeout, TimeUnit unit) {
long deadline = System.nanoTime() + unit.toNanos(timeout);

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.

TimeUnit.toNanos saturates, so a timeout within ~1µs of Long.MIN_VALUE nanos makes deadline - System.nanoTime() underflow and wrap to ~Long.MAX_VALUE: an effectively unbounded wait on the caller's thread, and nondeterministic (only when nanoTime advanced between the two reads). Ordinary negatives are fine — they produce an immediate false.

One-line fix: clamp the timeout to >= 0 before computing the deadline. (Positive extremes are already safe: (t0 + MAX) - t1 wraps back to MAX - elapsed.)

Map<String, LDClient> clients = getInstancesIfTheyIncludeThisClient();
if (clients.isEmpty()) {
// This client has been closed, or replaced by a later init; either way it can deliver
// nothing, and saying otherwise would tell the caller its events were safe.
return false;
}
// Every environment is started before any of them is waited on. Each has its own event
// processor and its own thread, so waiting on one before starting the next would spend the
// caller's budget on deliveries that could have been running all along.
List<Future<Boolean>> deliveries = new ArrayList<>(clients.size());
for (LDClient client : clients.values()) {
deliveries.add(client.eventProcessor.flushAsync());
}
boolean delivered = true;
for (Future<Boolean> delivery : deliveries) {
// Each wait gets what is left of the one budget rather than a fresh copy of it, so that
// the timeout the caller asked for is the time this call can take.
delivered &= awaitDelivery(delivery, Math.max(0, deadline - System.nanoTime()));
}
return delivered;
}
Comment thread
cursor[bot] marked this conversation as resolved.

private boolean awaitDelivery(Future<Boolean> delivery, long remainingNanos) {
try {
return Boolean.TRUE.equals(delivery.get(remainingNanos, TimeUnit.NANOSECONDS));
} catch (TimeoutException e) {
// Left running rather than cancelled: the events have been taken out of the buffer by
// now, so interrupting the delivery would only make losing them certain.
return false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} catch (ExecutionException e) {
Throwable cause = e.getCause() == null ? e : e.getCause();
logger.error("Exception caught when flushing events: {}", LogValues.exceptionSummary(cause));
logger.debug("{}", LogValues.exceptionTrace(cause));
return false;
}
}

@VisibleForTesting
void blockingFlush() {
eventProcessor.blockingFlush();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import java.io.Closeable;
import java.util.Map;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;

/**
* The interface for the LaunchDarkly SDK client.
Expand Down Expand Up @@ -146,6 +147,27 @@ public interface LDClientInterface extends Closeable {
*/
void flush();

/**
* Sends all pending events to LaunchDarkly and waits for them to be delivered.
* <p>
* Unlike {@link #flush()}, which returns before the events reach the network, this reports
* whether they arrived, which is what makes it usable at a point where the application is about
* to lose the ability to send them: an uncaught exception handler, a move to the background, or
* any other last chance. Events buffered in memory do not survive the process, so a caller that
* knows the process is ending can use this to give them one.
* <p>
* The timeout bounds the whole call, including when the SDK is configured for more than one
* environment. Choose it with the caller in mind: a dying process is not a good place to wait on
* a network request that may never answer.
*
* @param timeout how long to wait for delivery
* @param unit the time unit of {@code timeout}
* @return true if the events were delivered, or there were none to deliver; false if the timeout

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.

Two doc gaps worth closing here, since this javadoc is the one place customers will read:

  1. The spec asks that the reach of a bounded flush be stated plainly wherever it is documented. This names the uncaught-exception handler as a caller without saying what the mechanism cannot reach — SIGKILL, an ANR kill, a native crash, the system reclaiming a backgrounded process all run nothing. The right paragraph already exists in the test app's FlushOnCrashHandler header; it belongs here.
  2. @return: false on timeout does not mean the events were not sent — the delivery is deliberately left running and may still land. Callers planning compensating logic (persist-and-resend) need that stated, or every timeout that later succeeds becomes a duplicate. Also worth noting: the call blocks the calling thread (ANR consideration on main), and with the default sender a failing delivery takes ~21s, so budgets below that generally cannot observe a true on an unhealthy network.

* expired first, or the SDK is offline, closed, or otherwise unable to deliver them
* @since 5.17.0
*/
boolean flushAndWait(long timeout, TimeUnit unit);

/**
* Returns a map of all feature flags for the current evaluation context. No events are sent to LaunchDarkly.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
Expand Down Expand Up @@ -83,6 +84,28 @@ public static <T> LDAwaitFuture<T> fromFuture(Future<T> future) {
return result;
}

/**
* Runs a blocking call on a pooled daemon thread and reports its result as a future.
* <p>
* Use this where a caller has a deadline but the work it is waiting for has no way to take one.
* The call is left running if the caller stops waiting; nothing interrupts it.
*
* @param task the blocking call
* @param <T> result type
* @return a future that completes with the call's result, or with whatever it threw
*/
public static <T> Future<T> fromBlockingCall(Callable<T> task) {
LDAwaitFuture<T> result = new LDAwaitFuture<>();
getBridgeExecutor().execute(() -> {
try {
result.set(task.call());
} catch (Throwable t) {
result.setException(t);
}
});
return result;
}

/**
* Returns a future that completes when the first of the given futures completes.
* Equivalent to CompletableFuture.anyOf. Works with any {@link Future} (API-level safe).
Expand Down
Loading
Loading