From 1318e610baf96cb6d7afaa7e22fd1d9864c9433d Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 4 May 2026 15:44:39 -0400 Subject: [PATCH 1/2] fix: isFlushingEvents flag can get stuck true if publishEvents throws --- .../sdk/server/local/managers/EventQueueManager.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/devcycle/sdk/server/local/managers/EventQueueManager.java b/src/main/java/com/devcycle/sdk/server/local/managers/EventQueueManager.java index 59f2089d..6af0bacf 100644 --- a/src/main/java/com/devcycle/sdk/server/local/managers/EventQueueManager.java +++ b/src/main/java/com/devcycle/sdk/server/local/managers/EventQueueManager.java @@ -86,11 +86,14 @@ public synchronized void flushEvents() throws Exception { int eventCount = 0; isFlushingEvents = true; - for (FlushPayload payload : flushPayloads) { - eventCount += payload.eventCount; - publishEvents(this.sdkKey, payload); + try { + for (FlushPayload payload : flushPayloads) { + eventCount += payload.eventCount; + publishEvents(this.sdkKey, payload); + } + } finally { + isFlushingEvents = false; } - isFlushingEvents = false; DevCycleLogger.debug(String.format("DevCycle Flush %d AS Events, for %d Users", eventCount, flushPayloads.length)); } From 520161f8ed1f850d644022e6cd5ec8dee055d7b5 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Mon, 4 May 2026 15:55:56 -0400 Subject: [PATCH 2/2] test: verify isFlushingEvents resets on exception in flushEvents --- .../server/local/EventQueueManagerTest.java | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/test/java/com/devcycle/sdk/server/local/EventQueueManagerTest.java diff --git a/src/test/java/com/devcycle/sdk/server/local/EventQueueManagerTest.java b/src/test/java/com/devcycle/sdk/server/local/EventQueueManagerTest.java new file mode 100644 index 00000000..803a38aa --- /dev/null +++ b/src/test/java/com/devcycle/sdk/server/local/EventQueueManagerTest.java @@ -0,0 +1,69 @@ +package com.devcycle.sdk.server.local; + +import com.devcycle.sdk.server.common.api.IDevCycleApi; +import com.devcycle.sdk.server.common.model.DevCycleResponse; +import com.devcycle.sdk.server.helpers.WhiteBox; +import com.devcycle.sdk.server.local.bucketing.LocalBucketing; +import com.devcycle.sdk.server.local.managers.EventQueueManager; +import com.devcycle.sdk.server.local.model.DevCycleLocalOptions; +import com.devcycle.sdk.server.local.model.FlushPayload; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; +import retrofit2.Call; + +import java.lang.reflect.Field; + +import static org.junit.Assert.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; + +@RunWith(MockitoJUnitRunner.class) +public class EventQueueManagerTest { + + /** + * Verifies that isFlushingEvents is reset to false even when the publish loop throws. + * Without the try/finally fix, an exception escaping publishEvents() would leave + * isFlushingEvents stuck true, silently dropping all subsequent flushes. + */ + @Test + @SuppressWarnings("unchecked") + public void flushEvents_resetsIsFlushingEventsOnException() throws Exception { + LocalBucketing mockBucketing = Mockito.mock(LocalBucketing.class); + + // Large interval so the background scheduler doesn't fire during the test + DevCycleLocalOptions options = DevCycleLocalOptions.builder() + .eventFlushIntervalMS(Integer.MAX_VALUE) + .build(); + + // Return empty payloads by default so any early scheduler tick is a no-op + Mockito.when(mockBucketing.flushEventQueue(anyString())).thenReturn(new FlushPayload[0]); + + EventQueueManager manager = new EventQueueManager("server-key", mockBucketing, "test-uuid", options); + + // Swap in a mock API whose Call.execute() throws to simulate a publish failure + IDevCycleApi mockApi = Mockito.mock(IDevCycleApi.class); + Call mockCall = Mockito.mock(Call.class); + Mockito.when(mockApi.publishEvents(any())).thenReturn(mockCall); + Mockito.when(mockCall.execute()).thenThrow(new RuntimeException("simulated publish failure")); + WhiteBox.setInternalState(manager, "eventsApiClient", mockApi); + + // Return a non-empty payload so flushEvents() reaches the publish loop + FlushPayload payload = new FlushPayload(); + payload.payloadId = "test-payload-1"; + payload.eventCount = 1; + payload.records = new FlushPayload.Record[0]; + Mockito.when(mockBucketing.flushEventQueue(anyString())).thenReturn(new FlushPayload[]{payload}); + + try { + manager.flushEvents(); + } catch (RuntimeException e) { + // expected — the exception should escape flushEvents() + } + + Field field = EventQueueManager.class.getDeclaredField("isFlushingEvents"); + field.setAccessible(true); + assertFalse("isFlushingEvents must be reset to false after exception", (boolean) field.get(manager)); + } +}