diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e8fbd68a..1a27324ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Added a new config option `setWebviewDisplayOption(WebViewDisplayOption)` to control how Content and Feedback Widgets are displayed. * `IMMERSIVE` mode (default): Full-screen display (except cutouts). * `SAFE_AREA` mode: Omits status bar, navigation bar and cutouts when displaying webviews. +* Added a new init config option `disableGradualRequestCleaner()` to change request queue overflow behavior. When enabled, all overflowing requests (plus one slot) are removed at once instead of being cleaned gradually in limited batches. * Immediate requests now will be run by parallel executor instead of serial by default. diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyStoreRequestCleanerTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyStoreRequestCleanerTests.java new file mode 100644 index 000000000..ddac06e05 --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyStoreRequestCleanerTests.java @@ -0,0 +1,72 @@ +package ly.count.android.sdk; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; + +/** + * Tests for the request queue cleaning behavior with and without the gradual cleaner disabled. + */ +@RunWith(AndroidJUnit4.class) +public class CountlyStoreRequestCleanerTests { + CountlyStore store; + + @Before + public void setUp() { + store = new CountlyStore(TestUtils.getContext(), mock(ModuleLog.class)); + store.clear(); + } + + private void fillRequests(int count) { + for (int i = 0; i < count; i++) { + store.addRequest("req_" + i, false); + } + } + + @Test + public void testDefaultGradualCleanerRemovesUpToLoopLimit() { + store.maxRequestQueueSize = 1000; + fillRequests(200); + assertEquals(200, store.getRequests().length); + + // Reduce max to force clean on next add + store.maxRequestQueueSize = 50; // new limit + store.addRequest("req_new_default", false); + + // With gradual cleaner: removed Math.min(100, overflow(150)) + 1 = 101 before adding new + // Remaining after removal: 99, after adding new: 100 + String[] requests = store.getRequests(); + assertEquals(100, requests.length); + // First remaining element should be the original index 101 (req_101) + assertTrue("Expected first retained request to be req_101 but was " + requests[0], requests[0].equals("req_101")); + // Last element should be the newly added one + assertEquals("req_new_default", requests[requests.length - 1]); + } + + @Test + public void testDisableGradualRequestCleanerRemovesAllOverflow() { + store.maxRequestQueueSize = 1000; + fillRequests(200); + assertEquals(200, store.getRequests().length); + + // Enable new mode + store.setDisableGradualRequestCleaner(true); + + // Reduce max to force clean on next add + store.maxRequestQueueSize = 50; // new limit + store.addRequest("req_new_disabled", false); + + // With disabled gradual cleaner: removed overflow (150) + 1 = 151 before add + // Remaining after removal: 49, after adding new: 50 + String[] requests = store.getRequests(); + assertEquals(50, requests.length); + // First remaining element should be original index 151 (req_151) + assertTrue("Expected first retained request to be req_151 but was " + requests[0], requests[0].equals("req_151")); + // Last element should be the newly added one + assertEquals("req_new_disabled", requests[requests.length - 1]); + } +} diff --git a/sdk/src/main/java/ly/count/android/sdk/Countly.java b/sdk/src/main/java/ly/count/android/sdk/Countly.java index 26f7e8eea..c89a32dc0 100644 --- a/sdk/src/main/java/ly/count/android/sdk/Countly.java +++ b/sdk/src/main/java/ly/count/android/sdk/Countly.java @@ -458,6 +458,11 @@ public synchronized Countly init(CountlyConfig config) { L.d("[Init] request queue size set to [" + config.maxRequestQueueSize + "]"); countlyStore.setLimits(config.maxRequestQueueSize); + if (config.disableGradualRequestCleaner) { + L.d("[Init] Disabling gradual request queue cleaning. Overflow will be removed in one pass."); + countlyStore.setDisableGradualRequestCleaner(true); + } + if (config.storageProvider == null) { // outside of tests this should be null config.storageProvider = config.countlyStore; diff --git a/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java b/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java index fb48467c5..3785cfd30 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java @@ -210,6 +210,9 @@ public class CountlyConfig { boolean useSerialExecutor = false; WebViewDisplayOption webViewDisplayOption = WebViewDisplayOption.IMMERSIVE; + // If set to true, request queue cleaner will remove all overflow at once instead of gradually (loop limited) removing + boolean disableGradualRequestCleaner = false; + /** * THIS VARIABLE SHOULD NOT BE USED * IT IS ONLY FOR INTERNAL TESTING @@ -1083,6 +1086,20 @@ public synchronized CountlyConfig setUseSerialExecutor(boolean useSerial) { return this; } + /** + * Disable the gradual request cleaner. By default when the request queue exceeds the configured + * maximum size, only a limited number of the oldest requests are removed per cleanup cycle + * (capped by an internal loop limit of 100) to gradually shrink the queue. Calling this method changes + * the behavior so that whenever the queue exceeds the maximum size, all overflowing requests + * (plus one extra slot for the new request) are removed in a single operation. + * + * @return Returns the same config object for convenient linking + */ + public synchronized CountlyConfig disableGradualRequestCleaner() { + this.disableGradualRequestCleaner = true; + return this; + } + /** * APM configuration interface to be used with CountlyConfig */ diff --git a/sdk/src/main/java/ly/count/android/sdk/CountlyStore.java b/sdk/src/main/java/ly/count/android/sdk/CountlyStore.java index a67323bfd..f11b1e3b4 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyStore.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyStore.java @@ -86,6 +86,10 @@ public class CountlyStore implements StorageProvider, EventQueueProvider { int dropAgeHours = 0; private final static int requestRemovalLoopLimit = 100; + // If true, when cleaning request queue overflow remove all overflowing requests at once (plus one slot) + // instead of gradually removing up to 'requestRemovalLoopLimit' + boolean disableGradualRequestCleaner = false; + //explicit storage fields boolean explicitStorageModeEnabled; boolean esDirtyFlag = false; @@ -497,8 +501,15 @@ synchronized void deleteOldestRequests(List requests) { return; } - int requestsToRemove = Math.min(requestRemovalLoopLimit, requests.size() - maxRequestQueueSize) + 1; // +1 because it should open a new place for newcomer - L.i("[CountlyStore] deleteOldestRequests, Will remove the oldest " + requestsToRemove + " request"); + int overflow = requests.size() - maxRequestQueueSize; + int requestsToRemove; + if (disableGradualRequestCleaner) { + requestsToRemove = overflow + 1; + L.i("[CountlyStore] deleteOldestRequests, Gradual cleaner disabled. Removing all overflow: " + requestsToRemove + " request"); + } else { + requestsToRemove = Math.min(requestRemovalLoopLimit, overflow) + 1; // +1 because it should open a new place for newcomer + L.i("[CountlyStore] deleteOldestRequests, Will remove the oldest " + requestsToRemove + " request"); + } requests.subList(0, requestsToRemove).clear(); // sublist reflects all changes to the main list if (pcc != null) { @@ -506,6 +517,10 @@ synchronized void deleteOldestRequests(List requests) { } } + void setDisableGradualRequestCleaner(boolean disable) { + disableGradualRequestCleaner = disable; + } + synchronized void deleteOldestRequest_reworked() { long tsStart = 0L; if (pcc != null) {