diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java index 544c416b..d79fee13 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientEventTest.java @@ -18,6 +18,7 @@ import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes; import com.launchdarkly.sdk.android.integrations.DedupingHook; import com.launchdarkly.sdk.android.integrations.Hook; +import com.launchdarkly.sdk.android.subsystems.EventProcessor; import com.launchdarkly.sdk.android.subsystems.PersistentDataStore; import com.launchdarkly.sdk.internal.GsonHelpers; import com.launchdarkly.sdk.json.JsonSerialization; @@ -26,6 +27,9 @@ import org.junit.Test; import java.io.IOException; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; import okhttp3.HttpUrl; import okhttp3.mockwebserver.MockResponse; @@ -95,6 +99,110 @@ 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 flushAndWaitWithTheMostNegativeTimeoutDoesNotWait() throws IOException { + // toNanos saturates at Long.MIN_VALUE, and unclamped that wraps round to a wait for as long + // as the delivery takes -- which would then be reported as delivered. + try (MockWebServer mockEventsServer = new MockWebServer()) { + mockEventsServer.start(); + mockEventsServer.enqueue(new MockResponse().setHeadersDelay(3, TimeUnit.SECONDS)); + + LDConfig ldConfig = baseConfigBuilder(mockEventsServer).build(); + try (LDClient client = LDClient.init(application, ldConfig, ldContext, 0)) { + client.track("test-event"); + + long started = System.nanoTime(); + assertFalse(client.flushAndWait(Long.MIN_VALUE, TimeUnit.NANOSECONDS)); + long waitedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); + assertTrue("waited " + waitedMillis + "ms", waitedMillis < 1_000); + } + } + } + + @Test + public void flushAndWaitReportsFailureWhenTheDeliveryIsCancelled() throws IOException { + // A custom event processor may hand back a future that is cancelled; Future.get then throws + // CancellationException, which is unchecked and must not escape a boolean answer. + try (MockWebServer mockEventsServer = new MockWebServer()) { + mockEventsServer.start(); + + LDConfig ldConfig = baseConfigBuilder(mockEventsServer) + .events(clientContext -> new CancellingEventProcessor()) + .build(); + try (LDClient client = LDClient.init(application, ldConfig, ldContext, 0)) { + assertFalse(client.flushAndWait(5, TimeUnit.SECONDS)); + } + } + } + + /** An event processor whose deliveries are always cancelled before they can report. */ + private static final class CancellingEventProcessor implements EventProcessor { + @Override + public Future flushAsync() { + FutureTask delivery = new FutureTask<>(() -> true); + delivery.cancel(false); + return delivery; + } + + @Override + public void flush() {} + + @Override + public void blockingFlush() {} + + @Override + public void setInBackground(boolean inBackground) {} + + @Override + public void setOffline(boolean offline) {} + + @Override + public void close() {} + + @Override + public void recordEvaluationEvent(LDContext context, String flagKey, int flagVersion, + int variation, LDValue value, EvaluationReason reason, + LDValue defaultValue, boolean requireFullEvent, + Long debugEventsUntilDate) {} + + @Override + public void recordIdentifyEvent(LDContext context) {} + + @Override + public void recordCustomEvent(LDContext context, String eventKey, LDValue data, + Double metricValue) {} + } + @Test public void testTrackDataValueNull() throws IOException, InterruptedException { try (MockWebServer mockEventsServer = new MockWebServer()) { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java index 890e45dd..e996ab43 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java @@ -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 @@ -72,6 +73,12 @@ public void flush() {} @Override public void blockingFlush() {} + @Override + public Future flushAsync() { + // Nothing was recorded, so there is nothing undelivered to warn the caller about. + return new LDSuccessFuture<>(true); + } + @Override public void setInBackground(boolean inBackground) {} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DirectEventProcessor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DirectEventProcessor.java index 147a9fb8..5922eba4 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DirectEventProcessor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DirectEventProcessor.java @@ -133,6 +133,37 @@ 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} and {@link #pendingFlushAnswersACaller}. 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 pendingFlush; + + /** + * Whether a {@link #flushAsync()} caller, who is there to hear the outcome, is waiting on + * {@link #pendingFlush}, as opposed to only callers that discard it. + */ + private boolean pendingFlushAnswersACaller; + + /** + * Set when a delivery took events out of the buffer and did not get all of them to the service, + * and cleared once a {@link #flushAsync()} caller has been told so. + *

+ * Without it, a delivery that finds the buffer empty reports success even when the events it is + * being asked about were taken a moment earlier by another delivery that then lost them: the + * periodic flush, or a flush whose caller did not wait for the outcome. Only touched on the + * scheduler thread, which every delivery runs on. + */ + private boolean eventsLostSinceLastAnswer; + DirectEventProcessor( OutboundEventBuffer buffer, EventSender eventSender, @@ -334,7 +365,7 @@ public void flush() { if (isStopped()) { return; } - submit(this::deliverPayload); + queueDelivery(false); } @Override @@ -342,10 +373,7 @@ public void blockingFlush() { if (isStopped()) { return; } - Future delivery = submit(this::deliverPayload); - if (delivery == null) { - return; - } + Future delivery = queueDelivery(false); try { delivery.get(); } catch (InterruptedException e) { @@ -355,6 +383,79 @@ public void blockingFlush() { } } + @Override + public Future flushAsync() { + if (isStopped()) { + return new LDSuccessFuture<>(false); + } + return queueDelivery(true); + } + + /** + * Queues a delivery, or hands back one that is already queued and has not started. + *

+ * 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. + * + * @param answersACaller true if the caller will hear the outcome, so that the delivery reports + * any events lost since the last answer, and false if the caller discards it + */ + private Future queueDelivery(boolean answersACaller) { + synchronized (flushLock) { + if (pendingFlush != null) { + pendingFlushAnswersACaller |= answersACaller; + return pendingFlush; + } + LDAwaitFuture 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; + pendingFlushAnswersACaller = answersACaller; + return result; + } + } + + /** + * Runs one delivery on behalf of every flush request that joined it, and tells them all how it + * went. + *

+ * Deliveries run one at a time, so any delivery that was in flight when a caller asked has + * finished before this one starts, and has already recorded whether it lost what it took. A + * caller who will hear the answer is told no if anything was lost since the last caller was + * told, as well as if this delivery fails: an empty buffer is not evidence that the events + * which used to be in it arrived. + */ + private void runDelivery(LDAwaitFuture result) { + boolean answersACaller = false; + 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; + answersACaller = pendingFlushAnswersACaller; + pendingFlushAnswersACaller = false; + } + } + 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); + } + if (answersACaller) { + delivered &= !eventsLostSinceLastAnswer; + eventsLostSinceLastAnswer = false; + } + result.set(delivered); + } + @Override public void close() throws IOException { if (!closed.compareAndSet(false, true)) { @@ -368,22 +469,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(false).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 @@ -425,17 +527,30 @@ 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. - *

- * 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. + *

+ * 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, the service did not accept them, or some could not be serialized + */ + private boolean deliverPayloadReportingOutcome() { if (disabled || offline.get()) { - return; + // Nothing is taken, so nothing is lost: the events stay buffered for a later delivery. + return false; } List run; List summaries; @@ -445,24 +560,44 @@ private void deliverPayload() { summaries = buffer.takeSummaries(); summaryContextsExceeded.set(false); } + boolean delivered = false; + try { + delivered = deliverTaken(run, summaries); + } finally { + // From here the events exist only in this delivery, so not delivering them loses them, + // including when something unexpected is thrown on the way. + if (!delivered) { + eventsLostSinceLastAnswer = true; + } + } + return delivered; + } + + private boolean deliverTaken(List run, List summaries) { OutboundEventBuffer.Payload payload; try { payload = buffer.encode(run, summaries); } catch (IOException e) { logUnexpectedError(e); - return; + return false; } if (payload == null) { - return; + return true; + } + if (payload.getEventCount() == 0) { + return false; // everything taken was dropped as unserializable } 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() && payload.isComplete(); } catch (Exception e) { logUnexpectedError(e); + return false; } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index f224668a..acf5a877 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -37,6 +37,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; @@ -779,6 +780,55 @@ private void flushInternal() { eventProcessor.flush(); } + @Override + public boolean flushAndWait(long timeout, TimeUnit unit) { + // Clamped because toNanos saturates: a timeout at Long.MIN_VALUE nanos would make the + // remaining time below underflow, and wrap round to a wait with no bound at all. + long deadline = System.nanoTime() + Math.max(0, unit.toNanos(timeout)); + Map 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> deliveries = new ArrayList<>(clients.size()); + for (LDClient client : clients.values()) { + deliveries.add(client.eventProcessor.flushAsync()); + } + boolean delivered = true; + for (Future 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; + } + + private boolean awaitDelivery(Future 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 (CancellationException e) { + // Not something the SDK's own processor does, but a custom one can hand back a future + // that is cancelled, and that must not escape a call whose answer is a boolean. + 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(); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClientInterface.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClientInterface.java index dd62ec5c..b667adcb 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClientInterface.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClientInterface.java @@ -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. @@ -146,6 +147,39 @@ public interface LDClientInterface extends Closeable { */ void flush(); + /** + * Sends all pending events to LaunchDarkly and waits for them to be delivered. + *

+ * 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. + *

+ * It can only help while the process is still running code. An uncaught exception runs its + * handler first, and a move to the background is announced, so both leave time for this call. + * A {@code SIGKILL}, an ANR kill, a native crash, and the system reclaiming a backgrounded process + * run nothing at all, and events still in memory at that moment are lost whatever the + * application does. + *

+ * 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. The call blocks the thread it is made on, so on the + * main thread the timeout also counts towards an ANR. + * + * @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 + * expired first, the SDK is offline, closed, or otherwise unable to deliver them, or events + * recorded since the last time this was answered were lost on the way, by this delivery or an + * earlier one. A {@code false} because the timeout expired does not mean the events were not + * sent: the delivery is left running when the caller stops waiting, and may still arrive if + * the process lives long enough. A caller that resends on {@code false} can therefore cause + * duplicates. + * @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. * diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDFutures.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDFutures.java index c062d72e..8bed2024 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDFutures.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDFutures.java @@ -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; @@ -83,6 +84,28 @@ public static LDAwaitFuture fromFuture(Future future) { return result; } + /** + * Runs a blocking call on a pooled daemon thread and reports its result as a future. + *

+ * 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 result type + * @return a future that completes with the call's result, or with whatever it threw + */ + public static Future fromBlockingCall(Callable task) { + LDAwaitFuture 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). diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/OutboundEventBuffer.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/OutboundEventBuffer.java index 717dfa36..e1730568 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/OutboundEventBuffer.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/OutboundEventBuffer.java @@ -42,6 +42,7 @@ final class OutboundEventBuffer { private static final int INITIAL_OUTPUT_BUFFER_SIZE = 2000; private static final Event[] NO_EVENTS = new Event[0]; + private static final byte[] NO_DATA = new byte[0]; private static final List NO_SUMMARIES = Collections.emptyList(); private final EventOutputFormatter formatter; @@ -164,7 +165,8 @@ synchronized List takeSummaries() { * * @param run the full events to send, in the order they were recorded * @param summaries the counters taken alongside that run - * @return the payload to send, or null if there was nothing to send + * @return the payload to send, or null if there was nothing to send; a payload that had to drop + * something says so through {@link Payload#isComplete()}, and may then hold no events at all * @throws IOException if the events could not be serialized */ Payload encode(List run, List summaries) throws IOException { @@ -188,43 +190,55 @@ private Payload encodeAll(List run, List su if (outputEventCount == 0) { return null; } - return new Payload(buffer.toByteArray(), outputEventCount); + return new Payload(buffer.toByteArray(), outputEventCount, true); } private Payload encodeSkippingFailures(List run, List summaries) { List objects = new ArrayList<>(); int outputEventCount = 0; + boolean dropped = false; for (Event event : run) { EncodedPiece piece = tryEncode(new Event[] { event }, NO_SUMMARIES); if (piece == null) { logger.error("Dropping unserializable event of type {}", event.getClass().getSimpleName()); + dropped = true; continue; } - objects.add(piece.jsonObject); - outputEventCount += piece.eventCount; + if (piece != EncodedPiece.NOTHING) { + objects.add(piece.jsonObject); + outputEventCount += piece.eventCount; + } } for (EventSummarizer.EventSummary summary : summaries) { EncodedPiece piece = tryEncode(NO_EVENTS, Collections.singletonList(summary)); if (piece == null) { logger.error("Dropping unserializable summary event"); + dropped = true; continue; } - objects.add(piece.jsonObject); - outputEventCount += piece.eventCount; + if (piece != EncodedPiece.NOTHING) { + objects.add(piece.jsonObject); + outputEventCount += piece.eventCount; + } } if (objects.isEmpty()) { - return null; + return dropped ? new Payload(NO_DATA, 0, false) : null; } - return new Payload(joinObjects(objects), outputEventCount); + return new Payload(joinObjects(objects), outputEventCount, !dropped); } + /** + * @return the piece, {@link EncodedPiece#NOTHING} if the formatter had nothing to write for it, + * or null if it could not be serialized + */ private EncodedPiece tryEncode(Event[] events, List summaries) { try { ByteArrayOutputStream buffer = new ByteArrayOutputStream(INITIAL_OUTPUT_BUFFER_SIZE); int count = write(events, summaries, buffer); if (count == 0) { - return null; + // An empty summary, which the formatter skips. Nothing is lost by leaving it out. + return EncodedPiece.NOTHING; } byte[] jsonObject = objectFromArray(buffer.toByteArray()); if (jsonObject == null) { @@ -303,6 +317,8 @@ private static byte[] joinObjects(List objects) { } private static final class EncodedPiece { + static final EncodedPiece NOTHING = new EncodedPiece(NO_DATA, 0); + final byte[] jsonObject; final int eventCount; @@ -318,10 +334,12 @@ private static final class EncodedPiece { static final class Payload { private final byte[] data; private final int eventCount; + private final boolean complete; - Payload(byte[] data, int eventCount) { + Payload(byte[] data, int eventCount, boolean complete) { this.data = data; this.eventCount = eventCount; + this.complete = complete; } /** @@ -337,5 +355,13 @@ byte[] getData() { int getEventCount() { return eventCount; } + + /** + * @return false if something handed to the encoder could not be serialized and was dropped, + * so that even a successful post of this body leaves those events undelivered + */ + boolean isComplete() { + return complete; + } } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/EventProcessor.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/EventProcessor.java index e65a8e2b..4f8e8ce0 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/EventProcessor.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/EventProcessor.java @@ -4,8 +4,10 @@ import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.LDFutures; import java.io.Closeable; +import java.util.concurrent.Future; /** * Interface for an object that can send or store analytics events. @@ -99,4 +101,30 @@ void recordCustomEvent( * Specifies that any buffered events should be sent immediately, blocking until done. */ void blockingFlush(); + + /** + * Specifies that any buffered events should be sent immediately, and reports through the + * returned future whether they were delivered. + *

+ * This is the form the SDK itself uses, so that a caller with a deadline can wait for as long as + * it has and no longer, and so that several of these can be waited on together. Only the public + * API puts a timeout in a signature; see {@code LDClient.flushAndWait}. + *

+ * Nothing cancels the delivery when a caller stops waiting for it: by then the events have been + * taken out of the buffer, so interrupting the post would only make losing them certain. + * + * @return a future that completes with true if the events reached the service, or there were + * none to send; false if they could not be sent, including when a delivery that started + * earlier took them and then lost them + * @since 5.17.0 + */ + default Future flushAsync() { + // An implementation written before this method existed has only its unbounded blocking + // flush, so that runs on a thread of its own: the caller's deadline then bounds the wait + // rather than the flush, and the outcome it reports is still the flush's own. + return LDFutures.fromBlockingCall(() -> { + blockingFlush(); + return true; + }); + } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DirectEventProcessorTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DirectEventProcessorTest.java index a4acfd39..2a67c18c 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DirectEventProcessorTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DirectEventProcessorTest.java @@ -4,6 +4,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -30,10 +31,13 @@ import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -651,6 +655,22 @@ public void beingToldToShutDownStopsRecordingAndDelivery() throws Exception { } } + @Test + public void flushWithTimeoutReportsDeliveredEvents() throws Exception { + try (HttpServer server = startEventsServer()) { + EventProcessor eventProcessor = makeEventProcessor(server, DEFAULT_CAPACITY); + try { + eventProcessor.recordCustomEvent(CONTEXT, "an-event", LDValue.ofNull(), null); + + assertTrue(awaitFlush(eventProcessor, 10, TimeUnit.SECONDS)); + + assertEquals(1, countEventsOfKind(collectDelivered(server), "custom")); + } finally { + eventProcessor.close(); + } + } + } + @Test public void aFlushNeverSplitsAnEvaluationAcrossTwoPayloads() throws Exception { // The other half of the atomicity invariant. close() only ever delivers once, so it can show @@ -776,6 +796,21 @@ public Result sendAnalyticsEvents(byte[] data, int eventCount, URI eventsBaseUri } } + @Test + public void flushWithTimeoutReportsSuccessWhenThereIsNothingToSend() throws Exception { + try (HttpServer server = startEventsServer()) { + EventProcessor eventProcessor = makeEventProcessor(server, DEFAULT_CAPACITY); + try { + // Nothing was recorded, so the caller's events are not waiting anywhere. + assertTrue(awaitFlush(eventProcessor, 10, TimeUnit.SECONDS)); + + server.getRecorder().requireNoRequests(100, TimeUnit.MILLISECONDS); + } finally { + eventProcessor.close(); + } + } + } + @Test public void unexpectedRecordingErrorDoesNotBubbleToCallerAndLogs() throws Exception { ScheduledExecutorService scheduler = EventUtil.makeEventsTaskExecutor(); @@ -858,6 +893,190 @@ public Result sendAnalyticsEvents(byte[] data, int eventCount, URI eventsBaseUri } } + @Test + public void flushWithTimeoutReportsFailureWhileOffline() throws Exception { + try (HttpServer server = startEventsServer()) { + EventProcessor eventProcessor = makeEventProcessor(server, DEFAULT_CAPACITY); + try { + eventProcessor.setOffline(true); + eventProcessor.recordCustomEvent(CONTEXT, "an-event", LDValue.ofNull(), null); + + // The events are still buffered rather than delivered, and no amount of waiting + // changes that, so the caller is told so instead of being told they are safe. + assertFalse(awaitFlush(eventProcessor, 10, TimeUnit.SECONDS)); + + server.getRecorder().requireNoRequests(100, TimeUnit.MILLISECONDS); + } finally { + eventProcessor.close(); + } + } + } + + @Test + public void flushesArrivingWhileADeliveryRunsShareOneFollowUpDelivery() throws Exception { + // Otherwise a flush called faster than a post completes queues a post per call, and the + // flush that matters -- the one at shutdown, with a deadline -- waits behind all of them. + CountDownLatch firstSendStarted = new CountDownLatch(1); + CountDownLatch releaseFirstSend = new CountDownLatch(1); + AtomicInteger sends = new AtomicInteger(0); + EventSender sender = new StubEventSender() { + @Override + public Result sendAnalyticsEvents(byte[] data, int eventCount, URI eventsBaseUri) { + if (sends.incrementAndGet() == 1) { + firstSendStarted.countDown(); + awaitQuietly(releaseFirstSend, 5, TimeUnit.SECONDS); + } + return new Result(true, false, null); + } + }; + + ScheduledExecutorService scheduler = EventUtil.makeEventsTaskExecutor(); + DirectEventProcessor eventProcessor = makeEventProcessor(sender, NO_PERIODIC_FLUSH_MILLIS, + scheduler); + try { + eventProcessor.setOffline(false); + eventProcessor.recordCustomEvent(CONTEXT, "first", LDValue.ofNull(), null); + Future first = eventProcessor.flushAsync(); + assertTrue("the first delivery never started", + firstSendStarted.await(2, TimeUnit.SECONDS)); + + // The delivery thread is inside that post, so none of these can start, and each of them + // has to be answered by the one delivery that is queued behind it. + eventProcessor.recordCustomEvent(CONTEXT, "second", LDValue.ofNull(), null); + Future queued = eventProcessor.flushAsync(); + for (int i = 0; i < 50; i++) { + assertSame(queued, eventProcessor.flushAsync()); + } + + releaseFirstSend.countDown(); + assertTrue(first.get(5, TimeUnit.SECONDS)); + assertTrue(queued.get(5, TimeUnit.SECONDS)); + + assertEquals("one post for the running delivery and one for the 51 that joined", + 2, sends.get()); + } finally { + releaseFirstSend.countDown(); + eventProcessor.close(); + scheduler.shutdownNow(); + } + } + + @Test + public void aFlushJoiningADeliveryStillCoversWhatTheCallerRecorded() throws Exception { + // Joining is only sound while the delivery it joins has not taken the buffer yet, so what + // the joining caller recorded has to come back in that delivery's payload. + Semaphore letFirstResponseFinish = new Semaphore(0); + try (HttpServer server = HttpServer.start(Handlers.sequential( + Handlers.all(Handlers.waitFor(letFirstResponseFinish), Handlers.status(202)), + Handlers.status(202)))) { + EventProcessor eventProcessor = makeEventProcessor(server, DEFAULT_CAPACITY); + try { + eventProcessor.recordCustomEvent(CONTEXT, "first", LDValue.ofNull(), null); + Future first = eventProcessor.flushAsync(); + server.getRecorder().requireRequest(5, TimeUnit.SECONDS); + + eventProcessor.recordCustomEvent(CONTEXT, "joined", LDValue.ofNull(), null); + Future queued = eventProcessor.flushAsync(); + + letFirstResponseFinish.release(1); + assertTrue(first.get(5, TimeUnit.SECONDS)); + assertTrue(queued.get(5, TimeUnit.SECONDS)); + + RequestInfo second = server.getRecorder().requireRequest(5, TimeUnit.SECONDS); + assertTrue("the joining caller's event was left behind", + second.getBody().contains("\"key\":\"joined\"")); + } finally { + letFirstResponseFinish.release(Integer.MAX_VALUE); + eventProcessor.close(); + } + } + } + + @Test + public void flushWithTimeoutReportsFailureWhenTheTimeoutExpiresFirst() throws Exception { + Semaphore letResponseFinish = new Semaphore(0); + try (HttpServer server = HttpServer.start(Handlers.all(Handlers.waitFor(letResponseFinish), + Handlers.status(202)))) { + EventProcessor eventProcessor = makeEventProcessor(server, DEFAULT_CAPACITY); + try { + eventProcessor.recordCustomEvent(CONTEXT, "an-event", LDValue.ofNull(), null); + + assertFalse(awaitFlush(eventProcessor, 100, TimeUnit.MILLISECONDS)); + } finally { + // Released before closing, so that the delivery still in flight can finish rather + // than hold up the shutdown that close() waits on. + letResponseFinish.release(Integer.MAX_VALUE); + eventProcessor.close(); + } + } + } + + @Test + public void aFlushIsNotToldEventsArrivedThatAnEarlierDeliveryTookAndLost() throws Exception { + // By the time this flush runs the buffer is empty, which is also what it looks like when + // the events arrived, so only the earlier delivery's outcome can tell the two apart. + AtomicInteger sends = new AtomicInteger(0); + EventSender sender = new StubEventSender() { + @Override + public Result sendAnalyticsEvents(byte[] data, int eventCount, URI eventsBaseUri) { + return new Result(sends.incrementAndGet() > 1, false, null); + } + }; + ScheduledExecutorService scheduler = EventUtil.makeEventsTaskExecutor(); + DirectEventProcessor eventProcessor = makeEventProcessor(sender, NO_PERIODIC_FLUSH_MILLIS, + scheduler); + try { + eventProcessor.setOffline(false); + eventProcessor.recordCustomEvent(CONTEXT, "lost", LDValue.ofNull(), null); + eventProcessor.blockingFlush(); // takes the event, and its post fails unheard + + assertFalse(awaitFlush(eventProcessor, 5, TimeUnit.SECONDS)); + assertEquals("the second flush had nothing of its own to post", 1, sends.get()); + + // Once a caller has been told, the next is answered only for what came after. + eventProcessor.recordCustomEvent(CONTEXT, "delivered", LDValue.ofNull(), null); + assertTrue(awaitFlush(eventProcessor, 5, TimeUnit.SECONDS)); + } finally { + eventProcessor.close(); + scheduler.shutdownNow(); + } + } + + @Test + public void aFlushIsNotToldEventsArrivedThatCouldNotBeSerialized() throws Exception { + try (HttpServer server = startEventsServer()) { + EventProcessor eventProcessor = makeEventProcessor(server, DEFAULT_CAPACITY); + try { + eventProcessor.recordCustomEvent(CONTEXT, "poison", LDValue.ofNull(), Double.NaN); + + assertFalse(awaitFlush(eventProcessor, 10, TimeUnit.SECONDS)); + + server.getRecorder().requireNoRequests(100, TimeUnit.MILLISECONDS); + } finally { + eventProcessor.close(); + } + } + } + + @Test + public void aFlushIsNotToldEventsArrivedWhenSomeOfThemCouldNotBeSerialized() throws Exception { + try (HttpServer server = startEventsServer()) { + EventProcessor eventProcessor = makeEventProcessor(server, DEFAULT_CAPACITY); + try { + eventProcessor.recordCustomEvent(CONTEXT, "poison", LDValue.ofNull(), Double.NaN); + eventProcessor.recordCustomEvent(CONTEXT, "fine", LDValue.ofNull(), 1.0); + + // The post succeeds, and still not everything the caller recorded is in it. + assertFalse(awaitFlush(eventProcessor, 10, TimeUnit.SECONDS)); + + List events = collectDelivered(server); + assertEquals(LDValue.of("fine"), requireEventOfKind(events, "custom").get("key")); + } finally { + eventProcessor.close(); + } + } + } + @Test public void closeReleasesTheSenderOnlyAfterTheLastDeliveryFinishes() throws Exception { // Giving up on the wait must not turn into pulling the HTTP client out from under the @@ -1253,6 +1472,19 @@ private DiagnosticStore makeDiagnosticStore() { "android-client-sdk", "0.0.0", "Android", null, Collections.emptyMap(), null)); } + /** + * Flushes and waits for the outcome the way {@code LDClient.flushAndWait} does, which is the + * only place a timeout belongs. + */ + private static boolean awaitFlush(EventProcessor eventProcessor, long timeout, TimeUnit unit) + throws Exception { + try { + return Boolean.TRUE.equals(eventProcessor.flushAsync().get(timeout, unit)); + } catch (TimeoutException e) { + return false; + } + } + private static void awaitQuietly(CountDownLatch latch, long timeout, TimeUnit unit) { try { latch.await(timeout, unit); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EventProcessorFlushAsyncDefaultTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EventProcessorFlushAsyncDefaultTest.java new file mode 100644 index 00000000..427fc39a --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/EventProcessorFlushAsyncDefaultTest.java @@ -0,0 +1,90 @@ +package com.launchdarkly.sdk.android; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.android.subsystems.EventProcessor; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Covers what {@link EventProcessor#flushAsync()} does for an implementation that predates it and + * has only its unbounded {@link EventProcessor#blockingFlush()}. The SDK has to be able to put a + * deadline on such a flush, and must not tell the caller its events are safe without knowing. + */ +public class EventProcessorFlushAsyncDefaultTest { + @Rule + public Timeout globalTimeout = Timeout.seconds(30); + + @Test + public void theCallersDeadlineBoundsTheWaitAndNotTheFlush() throws Exception { + LegacyEventProcessor eventProcessor = new LegacyEventProcessor(); + Future delivery = eventProcessor.flushAsync(); + + try { + delivery.get(100, TimeUnit.MILLISECONDS); + fail("the wait outlived the deadline"); + } catch (TimeoutException expected) { + // The flush is still going, which is why this is what the caller is told. + } + assertFalse(eventProcessor.flushReturned.get()); + + eventProcessor.letFlushFinish.countDown(); + assertTrue("the flush's own outcome was not reported", + delivery.get(5, TimeUnit.SECONDS)); + assertTrue(eventProcessor.flushReturned.get()); + } + + /** An implementation written before {@code flushAsync} existed. */ + private static final class LegacyEventProcessor implements EventProcessor { + final CountDownLatch letFlushFinish = new CountDownLatch(1); + final AtomicBoolean flushReturned = new AtomicBoolean(false); + + @Override + public void blockingFlush() { + try { + letFlushFinish.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + flushReturned.set(true); + } + + @Override + public void flush() {} + + @Override + public void setInBackground(boolean inBackground) {} + + @Override + public void setOffline(boolean offline) {} + + @Override + public void close() {} + + @Override + public void recordEvaluationEvent(LDContext context, String flagKey, int flagVersion, + int variation, LDValue value, EvaluationReason reason, + LDValue defaultValue, boolean requireFullEvent, + Long debugEventsUntilDate) {} + + @Override + public void recordIdentifyEvent(LDContext context) {} + + @Override + public void recordCustomEvent(LDContext context, String eventKey, LDValue data, + Double metricValue) {} + } +} diff --git a/test-app/README.md b/test-app/README.md index e7d624ae..cd23fd36 100644 --- a/test-app/README.md +++ b/test-app/README.md @@ -11,9 +11,16 @@ launchdarkly.environment=production Set `launchdarkly.environment=staging` to use LaunchDarkly's staging endpoints. -## Tier 1 event-loss scenario +## Event-loss scenarios Create a boolean flag named `kill-flag`, or enter another flag key in the app. Tap **Eval+track+kill** to evaluate the flag, track a stand-in error event, request a flush, and terminate the process five seconds later. This exercises the interval between recording and delivery without Android lifecycle callbacks masking the result. + +The two immediate controls compare exits that application code can and cannot observe: + +- **Eval+Kill now** records the same pair and sends `SIGKILL` immediately. No handler or SDK code + can run before the process ends. +- **Eval+Crash now** throws an uncaught exception immediately after recording. The installed crash + handler calls `flushAndWait` with a two-second budget before delegating to Android's handler. diff --git a/test-app/src/main/java/com/launchdarkly/sdk/testapp/FlushOnCrashHandler.java b/test-app/src/main/java/com/launchdarkly/sdk/testapp/FlushOnCrashHandler.java new file mode 100644 index 00000000..be8f37e1 --- /dev/null +++ b/test-app/src/main/java/com/launchdarkly/sdk/testapp/FlushOnCrashHandler.java @@ -0,0 +1,67 @@ +package com.launchdarkly.sdk.testapp; + +import com.launchdarkly.sdk.android.LDClient; + +import java.util.concurrent.TimeUnit; + +import timber.log.Timber; + +/** + * Delivers buffered analytics events from the uncaught exception handler, which is the most an + * application can do about event loss while the SDK keeps its events only in memory. + *

+ * This is what makes the two instant buttons in {@link MainActivity} an experiment and its control. + * An uncaught exception runs this handler while the process is still alive and its other threads are + * still running, so the events recorded a moment earlier can still reach the network. + * {@code SIGKILL} runs nothing, and neither does an ANR, a native crash, or the system reclaiming a + * backgrounded process, so those lose the same events. The difference between the two buttons is the + * ground that on-disk persistence would cover and a crash handler cannot. + */ +final class FlushOnCrashHandler implements Thread.UncaughtExceptionHandler { + /** + * How long the crash is held open for the events. + *

+ * The SDK's HTTP timeouts are measured in seconds, and a request that hangs must not hold the + * process in a half-dead state for all of them: past this point the events are worth less than + * the delay, and the crash goes on to be reported. + */ + private static final long DELIVERY_BUDGET_MILLIS = 2000; + + private final Thread.UncaughtExceptionHandler next; + + private FlushOnCrashHandler(Thread.UncaughtExceptionHandler next) { + this.next = next; + } + + /** + * Installs the handler in front of whatever was already there, which on a real application is + * the crash reporter, and on this one is the platform handler that prints the trace. + */ + static void install() { + Thread.UncaughtExceptionHandler previous = Thread.getDefaultUncaughtExceptionHandler(); + if (previous instanceof FlushOnCrashHandler) { + return; + } + Thread.setDefaultUncaughtExceptionHandler(new FlushOnCrashHandler(previous)); + } + + @Override + public void uncaughtException(Thread thread, Throwable throwable) { + try { + // Waiting here on the crashing thread is safe because the timeout is the SDK's to + // enforce: it stops waiting on the delivery rather than trusting it to finish. That also + // covers the case where this crash is the reason the delivery cannot complete, such as + // an exception thrown while the event buffer was locked. + boolean delivered = LDClient.get() + .flushAndWait(DELIVERY_BUDGET_MILLIS, TimeUnit.MILLISECONDS); + Timber.w("crash handler: events delivered = %b", delivered); + } catch (Throwable t) { + // Nothing that happens in here is worth losing the crash report over. + Timber.e(t, "Could not deliver events from the crash handler"); + } finally { + if (next != null) { + next.uncaughtException(thread, throwable); + } + } + } +} diff --git a/test-app/src/main/java/com/launchdarkly/sdk/testapp/MainActivity.java b/test-app/src/main/java/com/launchdarkly/sdk/testapp/MainActivity.java index bbdd1e7a..c0a1fcb7 100644 --- a/test-app/src/main/java/com/launchdarkly/sdk/testapp/MainActivity.java +++ b/test-app/src/main/java/com/launchdarkly/sdk/testapp/MainActivity.java @@ -102,7 +102,12 @@ public void onCreate(Bundle savedInstanceState) { setupTrackButton(); setupIdentifyButton(); setupKillUnsentButton(); + setupKillNowButton(); + setupCrashNowButton(); setupOfflineSwitch(); + // Rescues the events for "Eval+Crash now" and cannot run for "Eval+Kill now", which is what + // makes the pair worth pressing. + FlushOnCrashHandler.install(); setupListeners(); updateDedupeStatus(); @@ -212,6 +217,31 @@ private void setupTrackButton() { }); } + /** + * The flag the kill and crash buttons evaluate: whatever is typed in the feature key field, or a + * default, so the buttons work without anything being typed first. + */ + private String flagKeyToKillOver() { + String typedKey = ((EditText) findViewById(R.id.feature_flag_key)).getText().toString().trim(); + return typedKey.isEmpty() ? "kill-flag" : typedKey; + } + + /** + * Records the pair whose survival is in question: an evaluation, which is the exposure, and a + * track, standing in for the error an application reports just before it dies. + * + *

Returns false when there is no client, in which case nothing was recorded and ending the + * process would demonstrate nothing. + */ + private boolean recordExposureAndError(String flagKey) { + if (ldClient == null) { + return false; + } + ldClient.boolVariation(flagKey, false); + ldClient.track("$ld:telemetry:error"); + return true; + } + /** * Reproduces in-memory event loss: evaluate (exposure) and track (stand-in for an error), * wait 5s so both calls are queued, then kill the process before the 30s flush. @@ -221,17 +251,58 @@ private void setupTrackButton() { private void setupKillUnsentButton() { Button killUnsentButton = findViewById(R.id.kill_unsent_button); killUnsentButton.setOnClickListener(v -> { - final String typedKey = ((EditText) findViewById(R.id.feature_flag_key)).getText().toString().trim(); - final String flagKey = typedKey.isEmpty() ? "kill-flag" : typedKey; + final String flagKey = flagKeyToKillOver(); Timber.w("eval+track+kill flag=%s", flagKey); - doSafeClientAction(() -> { - ldClient.boolVariation(flagKey, false); - ldClient.track("$ld:telemetry:error"); - ldClient.flush(); - new Handler(Looper.getMainLooper()).postDelayed( - () -> android.os.Process.killProcess(android.os.Process.myPid()), - 5_000); - }); + if (!recordExposureAndError(flagKey)) { + return; + } + ldClient.flush(); + new Handler(Looper.getMainLooper()).postDelayed( + () -> android.os.Process.killProcess(android.os.Process.myPid()), + 5_000); + }); + } + + /** + * The same sequence with nothing at all between the track and the process dying: no flush to + * deliver the events, no delay for a timer to fire in, and SIGKILL to itself, which cannot be + * caught, so no part of the SDK gets to run on the way out. + * + *

Whether the exposure and the track are reported therefore says exactly one thing: whether + * recording them had already put them somewhere that outlives the process. They should arrive on + * the next launch of the app, not this one. + */ + private void setupKillNowButton() { + Button killNowButton = findViewById(R.id.kill_now_button); + killNowButton.setOnClickListener(v -> { + final String flagKey = flagKeyToKillOver(); + Timber.w("eval+track+kill now flag=%s", flagKey); + if (!recordExposureAndError(flagKey)) { + return; + } + android.os.Process.killProcess(android.os.Process.myPid()); + }); + } + + /** + * The same again, ending in an uncaught exception instead of a signal the process never sees. + * + *

This is the shape a customer report takes: app code fails immediately after reporting the + * failure. Unlike SIGKILL, an uncaught exception runs the default handler before the process + * goes, so this is the one variant an application can rescue on its own, which + * {@link FlushOnCrashHandler} does by calling {@link LDClient#flushAndWait} from there. So these + * events should arrive and the ones from the button next to it should not. + */ + private void setupCrashNowButton() { + Button crashNowButton = findViewById(R.id.crash_now_button); + crashNowButton.setOnClickListener(v -> { + final String flagKey = flagKeyToKillOver(); + Timber.w("eval+track+crash now flag=%s", flagKey); + if (!recordExposureAndError(flagKey)) { + return; + } + throw new RuntimeException( + "Eval+Crash: deliberate uncaught exception immediately after track, to test event persistence"); }); } diff --git a/test-app/src/main/res/layout/activity_main.xml b/test-app/src/main/res/layout/activity_main.xml index dfa8fac0..3b377995 100644 --- a/test-app/src/main/res/layout/activity_main.xml +++ b/test-app/src/main/res/layout/activity_main.xml @@ -114,8 +114,12 @@ android:layout_alignParentRight="true" android:minLines="4" /> -