From d4357607ee43c01c0a28f376eb90e4265a9ee05a Mon Sep 17 00:00:00 2001 From: Junias Date: Mon, 20 Apr 2026 14:04:42 -0400 Subject: [PATCH 1/4] TRIAGE-608: Add max persistence age override option --- .../com.mparticle/MParticleOptionsTest.kt | 40 +++++++++++ .../database/services/MessageServiceTest.kt | 66 +++++++++++++++++++ .../java/com/mparticle/MParticleOptions.java | 51 ++++++++++++++ .../com/mparticle/internal/ConfigManager.java | 13 ++++ .../com/mparticle/internal/UploadHandler.java | 46 +++++++++++++ .../database/services/MParticleDBManager.java | 25 +++++++ .../database/services/MessageService.java | 16 +++++ .../database/services/SessionService.java | 17 +++++ .../database/services/UploadService.java | 16 +++++ 9 files changed, 290 insertions(+) diff --git a/android-core/src/androidTest/kotlin/com.mparticle/MParticleOptionsTest.kt b/android-core/src/androidTest/kotlin/com.mparticle/MParticleOptionsTest.kt index 826b3139b..6249078c0 100644 --- a/android-core/src/androidTest/kotlin/com.mparticle/MParticleOptionsTest.kt +++ b/android-core/src/androidTest/kotlin/com.mparticle/MParticleOptionsTest.kt @@ -694,6 +694,46 @@ class MParticleOptionsTest : BaseAbstractTest() { Assert.assertNull(options.configMaxAge) } + @Test + fun testPersistenceMaxAgeSeconds() { + // nothing set, should return null (SDK will fall back to the 90-day default) + var options = + MParticleOptions + .builder(mContext) + .credentials("key", "secret") + .build() + Assert.assertNull(options.persistenceMaxAgeSeconds) + + // positive number should be preserved + val testValue = Math.abs(ran.nextInt()) + 1 + options = + MParticleOptions + .builder(mContext) + .credentials("key", "secret") + .persistenceMaxAgeSeconds(testValue) + .build() + Assert.assertEquals(testValue, options.persistenceMaxAgeSeconds) + + // zero is non-positive and should be rejected (differs from configMaxAgeSeconds which + // accepts zero as "always stale") - mirrors iOS SDK behaviour + options = + MParticleOptions + .builder(mContext) + .credentials("key", "secret") + .persistenceMaxAgeSeconds(0) + .build() + Assert.assertNull(options.persistenceMaxAgeSeconds) + + // negative numbers should be rejected + options = + MParticleOptions + .builder(mContext) + .credentials("key", "secret") + .persistenceMaxAgeSeconds(-5) + .build() + Assert.assertNull(options.persistenceMaxAgeSeconds) + } + @Test fun testAndroidIdLogMessage() { val infoLogs = ArrayList() diff --git a/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/MessageServiceTest.kt b/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/MessageServiceTest.kt index 9b1db3a01..290e4b95d 100644 --- a/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/MessageServiceTest.kt +++ b/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/MessageServiceTest.kt @@ -400,6 +400,72 @@ class MessageServiceTest : BaseMPServiceTest() { Assert.assertEquals(MessageService.getMessagesForUpload(database).size.toLong(), 20) } + @Test + @Throws(JSONException::class) + fun testDeleteMessagesOlderThan() { + val sessionId = UUID.randomUUID().toString() + val now = System.currentTimeMillis() + val oneDayMillis = 24L * 60L * 60L * 1000L + // Insert 5 "old" messages dated 10 days ago and 5 "recent" messages dated 1 hour ago. + for (i in 0 until 5) { + val oldMessage = + BaseMPMessage + .Builder("custom_event") + .timestamp(now - 10L * oneDayMillis) + .build( + InternalSession().apply { mSessionID = sessionId }, + null, + 1L, + ) + MessageService.insertMessage(database, "apiKey", oldMessage, 1L, null, null) + } + for (i in 0 until 5) { + val recentMessage = + BaseMPMessage + .Builder("custom_event") + .timestamp(now - 60L * 60L * 1000L) + .build( + InternalSession().apply { mSessionID = sessionId }, + null, + 1L, + ) + MessageService.insertMessage(database, "apiKey", recentMessage, 1L, null, null) + } + Assert.assertEquals( + 10L, + MessageService.getMessagesForUpload(database).size.toLong(), + ) + + // Cut off at 7 days ago - the 5 old messages should be removed and the 5 recent kept. + val cutoffMillis = now - 7L * oneDayMillis + val deleted = MessageService.deleteMessagesOlderThan(database, cutoffMillis) + Assert.assertEquals(5, deleted.toLong()) + Assert.assertEquals( + 5L, + MessageService.getMessagesForUpload(database).size.toLong(), + ) + + // Rows exactly at the cutoff must not be removed (strict `<` predicate). + val exactlyAtCutoffMessage = + BaseMPMessage + .Builder("custom_event") + .timestamp(cutoffMillis) + .build( + InternalSession().apply { mSessionID = sessionId }, + null, + 1L, + ) + MessageService.insertMessage(database, "apiKey", exactlyAtCutoffMessage, 1L, null, null) + Assert.assertEquals( + 0, + MessageService.deleteMessagesOlderThan(database, cutoffMillis).toLong(), + ) + Assert.assertEquals( + 6L, + MessageService.getMessagesForUpload(database).size.toLong(), + ) + } + private fun getMaxId(messages: List): Int { var max = 0 for (message in messages) { diff --git a/android-core/src/main/java/com/mparticle/MParticleOptions.java b/android-core/src/main/java/com/mparticle/MParticleOptions.java index e6fd4c123..3b4002224 100644 --- a/android-core/src/main/java/com/mparticle/MParticleOptions.java +++ b/android-core/src/main/java/com/mparticle/MParticleOptions.java @@ -42,6 +42,7 @@ public class MParticleOptions { private Integer mUploadInterval = ConfigManager.DEFAULT_UPLOAD_INTERVAL; //seconds private Integer mSessionTimeout = ConfigManager.DEFAULT_SESSION_TIMEOUT_SECONDS; //seconds private Integer mConfigMaxAge = null; + private Integer mPersistenceMaxAgeSeconds = null; private Boolean mUnCaughtExceptionLogging = false; private MParticle.LogLevel mLogLevel = MParticle.LogLevel.DEBUG; private AttributionListener mAttributionListener; @@ -118,6 +119,13 @@ public MParticleOptions(@NonNull Builder builder) { this.mConfigMaxAge = builder.configMaxAge; } } + if (builder.persistenceMaxAgeSeconds != null) { + if (builder.persistenceMaxAgeSeconds <= 0) { + Logger.warning("Persistence Max Age must be a positive number, disregarding value."); + } else { + this.mPersistenceMaxAgeSeconds = builder.persistenceMaxAgeSeconds; + } + } if (builder.unCaughtExceptionLogging != null) { this.mUnCaughtExceptionLogging = builder.unCaughtExceptionLogging; } @@ -290,6 +298,22 @@ public Integer getConfigMaxAge() { return mConfigMaxAge; } + /** + * The maximum threshold (in seconds) for locally persisted events, batches, and sessions. + *

+ * When {@code null} (the default), records are retained for 90 days before being deleted. + * Values less than or equal to zero are rejected at build time and result in the default + * being used. + * + * @return the configured maximum persistence age in seconds, or {@code null} when the default + * (90 days) applies + * @see Builder#persistenceMaxAgeSeconds(int) + */ + @Nullable + public Integer getPersistenceMaxAgeSeconds() { + return mPersistenceMaxAgeSeconds; + } + @NonNull public Boolean isUncaughtExceptionLoggingEnabled() { return mUnCaughtExceptionLogging; @@ -403,6 +427,7 @@ public static class Builder { private Integer uploadInterval = null; private Integer sessionTimeout = null; private Integer configMaxAge = null; + private Integer persistenceMaxAgeSeconds = null; private Boolean unCaughtExceptionLogging = null; MParticle.LogLevel logLevel = null; BaseIdentityTask identityTask; @@ -622,6 +647,32 @@ public Builder configMaxAgeSeconds(int configMaxAge) { return this; } + /** + * Set a maximum threshold for locally persisted events, batches, and sessions, in seconds. + *

+ * By default, data is persisted for 90 days before being deleted to minimize data loss; + * however, this can lead to excessive storage usage on some users' devices. This is + * exacerbated if your app logs a large number of events, or events carrying a lot of data + * (attributes, etc.). + *

+ * Set a lower value (for example, 48 hours or 1 week) if you have storage usage concerns. + * Alternatively, if you have data loss concerns, set a longer value than the default. + *

+ * This is the Android equivalent of the iOS SDK's + * {@code MParticleOptions.persistenceMaxAgeSeconds} option. + * + * @param persistenceMaxAgeSeconds the upper limit, in seconds, for how long persisted + * data may live on disk. Must be greater than zero; + * non-positive values are rejected and the default + * (90 days) is used instead + * @return the instance of the builder, for chaining calls + */ + @NonNull + public Builder persistenceMaxAgeSeconds(int persistenceMaxAgeSeconds) { + this.persistenceMaxAgeSeconds = persistenceMaxAgeSeconds; + return this; + } + /** * Enable or disable mParticle exception handling to automatically log events on uncaught exceptions. * diff --git a/android-core/src/main/java/com/mparticle/internal/ConfigManager.java b/android-core/src/main/java/com/mparticle/internal/ConfigManager.java index 8b7d61569..105dd8d54 100644 --- a/android-core/src/main/java/com/mparticle/internal/ConfigManager.java +++ b/android-core/src/main/java/com/mparticle/internal/ConfigManager.java @@ -106,6 +106,8 @@ public class ConfigManager { private String mDataplanId; private Integer mDataplanVersion; private Integer mMaxConfigAge; + @Nullable + private Integer mPersistenceMaxAgeSeconds; public static final int DEFAULT_CONNECTION_TIMEOUT_SECONDS = 30; public static final int MINIMUM_CONNECTION_TIMEOUT_SECONDS = 1; public static final int DEFAULT_SESSION_TIMEOUT_SECONDS = 60; @@ -136,6 +138,17 @@ public ConfigManager(Context context) { public ConfigManager(@NonNull MParticleOptions options) { this(options.getContext(), options.getEnvironment(), options.getApiKey(), options.getApiSecret(), options.getDataplanOptions(), options.getDataplanId(), options.getDataplanVersion(), options.getConfigMaxAge(), options.getConfigurationsForTarget(ConfigManager.class), options.getSideloadedKits()); + mPersistenceMaxAgeSeconds = options.getPersistenceMaxAgeSeconds(); + } + + /** + * @return the configured maximum persistence age in seconds, or {@code null} when the SDK + * should fall back to its 90-day default. + * @see MParticleOptions.Builder#persistenceMaxAgeSeconds(int) + */ + @Nullable + public Integer getPersistenceMaxAgeSeconds() { + return mPersistenceMaxAgeSeconds; } public ConfigManager(@NonNull Context context, @Nullable MParticle.Environment environment, @Nullable String apiKey, @Nullable String apiSecret, @Nullable MParticleOptions.DataplanOptions dataplanOptions, @Nullable String dataplanId, @Nullable Integer dataplanVersion, @Nullable Integer configMaxAge, @Nullable List> configurations, @Nullable List sideloadedKits) { diff --git a/android-core/src/main/java/com/mparticle/internal/UploadHandler.java b/android-core/src/main/java/com/mparticle/internal/UploadHandler.java index 38e68d7db..5bfe5cea6 100644 --- a/android-core/src/main/java/com/mparticle/internal/UploadHandler.java +++ b/android-core/src/main/java/com/mparticle/internal/UploadHandler.java @@ -56,6 +56,25 @@ public class UploadHandler extends BaseHandler { */ public static final int INIT_CONFIG = 6; + /** + * Default retention window for persisted events, batches, and sessions when + * {@link com.mparticle.MParticleOptions.Builder#persistenceMaxAgeSeconds(int)} is not set. + * Matches the iOS SDK's 90-day default. + */ + static final long DEFAULT_PERSISTENCE_MAX_AGE_MILLIS = 90L * 24L * 60L * 60L * 1000L; + + /** + * Minimum interval between age-based persistence sweeps, matching the iOS SDK's 24 hour + * throttle on {@code cleanUp}. + */ + static final long PERSISTENCE_CLEANUP_INTERVAL_MILLIS = 24L * 60L * 60L * 1000L; + + /** + * Unix-epoch millisecond timestamp of the last successful age-based sweep. Zero means + * "never run in this process". + */ + private long mLastPersistenceCleanupMillis = 0L; + private final SharedPreferences mPreferences; private final SegmentDatabase audienceDB; @@ -186,6 +205,7 @@ public void prepareMessageUploads(UploadSettings uploadSettings) throws Exceptio * This method is responsible for looking for batches that are ready to be uploaded, and uploading them. */ protected void upload() { + maybePrunePersistedRecords(System.currentTimeMillis()); mParticleDBManager.cleanupUploadMessages(); try { List readyUploads = mParticleDBManager.getReadyUploads(); @@ -211,6 +231,32 @@ protected void upload() { } } + /** + * Run an age-based retention sweep across persisted events, batches, and sessions at most + * once every {@link #PERSISTENCE_CLEANUP_INTERVAL_MILLIS}. When the consumer has not + * configured {@link com.mparticle.MParticleOptions.Builder#persistenceMaxAgeSeconds(int)}, + * the default 90-day window is used. + * + * @param nowMillis current time in unix-epoch milliseconds + */ + void maybePrunePersistedRecords(long nowMillis) { + if (nowMillis - mLastPersistenceCleanupMillis < PERSISTENCE_CLEANUP_INTERVAL_MILLIS) { + return; + } + try { + Integer configured = mConfigManager == null ? null : mConfigManager.getPersistenceMaxAgeSeconds(); + long maxAgeMillis = (configured == null) + ? DEFAULT_PERSISTENCE_MAX_AGE_MILLIS + : configured.longValue() * 1000L; + long cutoffMillis = nowMillis - maxAgeMillis; + mParticleDBManager.deleteRecordsOlderThan(cutoffMillis); + } catch (Exception e) { + Logger.warning(e, "Failed to prune persisted records by age."); + } finally { + mLastPersistenceCleanupMillis = nowMillis; + } + } + void uploadMessage(int id, String message, UploadSettings uploadSettings) throws IOException, MParticleApiClientImpl.MPThrottleException { int responseCode = -1; boolean sampling = false; diff --git a/android-core/src/main/java/com/mparticle/internal/database/services/MParticleDBManager.java b/android-core/src/main/java/com/mparticle/internal/database/services/MParticleDBManager.java index 9e9015946..8eb5d8594 100644 --- a/android-core/src/main/java/com/mparticle/internal/database/services/MParticleDBManager.java +++ b/android-core/src/main/java/com/mparticle/internal/database/services/MParticleDBManager.java @@ -206,6 +206,31 @@ public void deleteMessagesAndSessions(String currentSessionId) { } } + /** + * Age-based retention sweep across the three persistence tables. + *

+ * Deletes any messages and uploads whose {@code CREATED_AT} is strictly less than + * {@code cutoffMillis}, and any sessions whose {@code END_TIME} is strictly less than + * {@code cutoffMillis}. Mirrors the behaviour of the iOS SDK's + * {@code MPPersistenceController.deleteRecordsOlderThan:}. + * + * @param cutoffMillis the unix-epoch millisecond cutoff; rows older than this are removed + */ + public void deleteRecordsOlderThan(long cutoffMillis) { + MPDatabase db = getDatabase(); + try { + db.beginTransaction(); + MessageService.deleteMessagesOlderThan(db, cutoffMillis); + UploadService.deleteUploadsOlderThan(db, cutoffMillis); + SessionService.deleteSessionsOlderThan(db, cutoffMillis); + db.setTransactionSuccessful(); + } catch (Exception e) { + Logger.warning(e, "Error pruning persisted records older than " + cutoffMillis + " ms."); + } finally { + db.endTransaction(); + } + } + private HashMap getUploadMessageByBatchIdMap(List readyMessages, MPDatabase db, ConfigManager configManager) throws JSONException { return getUploadMessageByBatchIdMap(readyMessages, db, configManager, false); } diff --git a/android-core/src/main/java/com/mparticle/internal/database/services/MessageService.java b/android-core/src/main/java/com/mparticle/internal/database/services/MessageService.java index 135352ef8..38814cf34 100644 --- a/android-core/src/main/java/com/mparticle/internal/database/services/MessageService.java +++ b/android-core/src/main/java/com/mparticle/internal/database/services/MessageService.java @@ -105,6 +105,22 @@ public static int deleteOldMessages(MPDatabase database, String currentSessionId selectionArgs); } + /** + * Delete messages whose {@link MessageTableColumns#CREATED_AT} is strictly less than + * {@code cutoffMillis}. + * + * @param database the message database + * @param cutoffMillis the unix-epoch millisecond cutoff; rows older than this are removed + * @return the number of rows deleted + */ + public static int deleteMessagesOlderThan(MPDatabase database, long cutoffMillis) { + String[] whereArgs = new String[]{Long.toString(cutoffMillis)}; + return database.delete( + MessageTableColumns.TABLE_NAME, + MessageTableColumns.CREATED_AT + " < ?", + whereArgs); + } + public static boolean hasMessagesForUpload(MPDatabase database) { Cursor messageIds = null; try { diff --git a/android-core/src/main/java/com/mparticle/internal/database/services/SessionService.java b/android-core/src/main/java/com/mparticle/internal/database/services/SessionService.java index 142cf11fb..7b33fd6fd 100644 --- a/android-core/src/main/java/com/mparticle/internal/database/services/SessionService.java +++ b/android-core/src/main/java/com/mparticle/internal/database/services/SessionService.java @@ -33,6 +33,23 @@ public static int deleteSessions(MPDatabase database, String currentSessionId) { return database.delete(TABLE_NAME, SessionTableColumns.SESSION_ID + "!=? ", selectionArgs); } + /** + * Delete sessions whose {@link SessionTableColumns#END_TIME} is strictly less than + * {@code cutoffMillis}. + * + * @param database the session database + * @param cutoffMillis the unix-epoch millisecond cutoff; sessions that ended before this are + * removed + * @return the number of rows deleted + */ + public static int deleteSessionsOlderThan(MPDatabase database, long cutoffMillis) { + String[] whereArgs = new String[]{Long.toString(cutoffMillis)}; + return database.delete( + TABLE_NAME, + SessionTableColumns.END_TIME + " < ?", + whereArgs); + } + /** * delete Session entries with session_id that are not a part of the Set * diff --git a/android-core/src/main/java/com/mparticle/internal/database/services/UploadService.java b/android-core/src/main/java/com/mparticle/internal/database/services/UploadService.java index 912c40aeb..90e4809d3 100644 --- a/android-core/src/main/java/com/mparticle/internal/database/services/UploadService.java +++ b/android-core/src/main/java/com/mparticle/internal/database/services/UploadService.java @@ -22,6 +22,22 @@ public static int cleanupUploadMessages(MPDatabase database) { return database.delete(UploadTableColumns.TABLE_NAME, "length(" + UploadTableColumns.MESSAGE + ") > " + Constants.LIMIT_MAX_UPLOAD_SIZE, null); } + /** + * Delete uploads whose {@link UploadTableColumns#CREATED_AT} is strictly less than + * {@code cutoffMillis}. + * + * @param database the upload database + * @param cutoffMillis the unix-epoch millisecond cutoff; rows older than this are removed + * @return the number of rows deleted + */ + public static int deleteUploadsOlderThan(MPDatabase database, long cutoffMillis) { + String[] whereArgs = new String[]{Long.toString(cutoffMillis)}; + return database.delete( + UploadTableColumns.TABLE_NAME, + UploadTableColumns.CREATED_AT + " < ?", + whereArgs); + } + /** * Generic method to insert a new upload, * either a regular message batch, or a session history. From 676f3ae97978571cfa8b325832bacfa4b90840cf Mon Sep 17 00:00:00 2001 From: Junias Date: Mon, 20 Apr 2026 15:35:31 -0400 Subject: [PATCH 2/4] TRIAGE-608: Update throttl ts on success + tests --- .../database/services/SessionServiceTest.kt | 54 +++++++++++++++++++ .../database/services/UploadServiceTest.kt | 52 ++++++++++++++++++ .../com/mparticle/internal/UploadHandler.java | 10 ++-- .../mparticle/internal/UploadHandlerTest.kt | 24 +++++++++ 4 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/UploadServiceTest.kt diff --git a/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/SessionServiceTest.kt b/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/SessionServiceTest.kt index 80ab0de18..eeb790001 100644 --- a/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/SessionServiceTest.kt +++ b/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/SessionServiceTest.kt @@ -4,6 +4,7 @@ import android.database.Cursor import com.mparticle.internal.BatchId import com.mparticle.internal.MessageBatch import com.mparticle.internal.database.tables.SessionTable +import org.json.JSONException import org.json.JSONObject import org.junit.Assert import org.junit.Assert.assertEquals @@ -76,6 +77,59 @@ class SessionServiceTest : BaseMPServiceTest() { } } + @Test + @Throws(JSONException::class) + fun testDeleteSessionsOlderThan() { + val now = System.currentTimeMillis() + val oneDayMillis = 24L * 60L * 60L * 1000L + val oldEndTime = now - 10L * oneDayMillis + val recentEndTime = now - 60L * 60L * 1000L + + // Insert 5 sessions whose END_TIME is 10 days ago and 5 whose END_TIME is 1 hour ago. + // insertSession seeds END_TIME = START_TIME, so we call updateSessionEndTime to model + // the production flow where subsequent events advance END_TIME independently. + for (i in 0 until 5) { + val oldSessionId = UUID.randomUUID().toString() + SessionService.insertSession(database, getMpMessage(oldSessionId), "apiKey", "{}", "{}", 1L) + SessionService.updateSessionEndTime(database, oldSessionId, oldEndTime, 0) + } + for (i in 0 until 5) { + val recentSessionId = UUID.randomUUID().toString() + SessionService.insertSession(database, getMpMessage(recentSessionId), "apiKey", "{}", "{}", 1L) + SessionService.updateSessionEndTime(database, recentSessionId, recentEndTime, 0) + } + assertEquals(10, countSessions()) + + // Cut off at 7 days ago - the 5 old sessions should be removed and the 5 recent kept. + val cutoffMillis = now - 7L * oneDayMillis + val deleted = SessionService.deleteSessionsOlderThan(database, cutoffMillis) + assertEquals(5, deleted) + assertEquals(5, countSessions()) + + // Rows whose END_TIME is exactly at the cutoff must not be removed (strict `<` predicate). + val boundarySessionId = UUID.randomUUID().toString() + SessionService.insertSession(database, getMpMessage(boundarySessionId), "apiKey", "{}", "{}", 1L) + SessionService.updateSessionEndTime(database, boundarySessionId, cutoffMillis, 0) + assertEquals(0, SessionService.deleteSessionsOlderThan(database, cutoffMillis)) + assertEquals(6, countSessions()) + } + + private fun countSessions(): Int { + var count = 0 + var cursor: Cursor? = null + try { + cursor = SessionService.getSessions(database) + while (cursor.moveToNext()) { + count++ + } + } finally { + if (cursor != null && !cursor.isClosed) { + cursor.close() + } + } + return count + } + internal inner class MockMessageBatch( var id: Int, ) : MessageBatch() { diff --git a/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/UploadServiceTest.kt b/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/UploadServiceTest.kt new file mode 100644 index 000000000..5b2ed9895 --- /dev/null +++ b/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/UploadServiceTest.kt @@ -0,0 +1,52 @@ +package com.mparticle.internal.database.services + +import com.mparticle.internal.Constants +import com.mparticle.internal.database.UploadSettings +import com.mparticle.networking.NetworkOptions +import org.json.JSONException +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Test + +class UploadServiceTest : BaseMPServiceTest() { + + @Test + @Throws(JSONException::class) + fun testDeleteUploadsOlderThan() { + val now = System.currentTimeMillis() + val oneDayMillis = 24L * 60L * 60L * 1000L + val uploadSettings = UploadSettings( + "apiKey", + "secret", + NetworkOptions.builder().build(), + "", + "", + ) + + // Insert 5 uploads dated 10 days ago and 5 uploads dated 1 hour ago. + // insertUpload reads CREATED_AT from the message's TIMESTAMP ("ct") key. + for (i in 0 until 5) { + UploadService.insertUpload(database, uploadJson(now - 10L * oneDayMillis), uploadSettings) + } + for (i in 0 until 5) { + UploadService.insertUpload(database, uploadJson(now - 60L * 60L * 1000L), uploadSettings) + } + assertEquals(10, UploadService.getReadyUploads(database).size) + + // Cut off at 7 days ago - the 5 old uploads should be removed and the 5 recent kept. + val cutoffMillis = now - 7L * oneDayMillis + val deleted = UploadService.deleteUploadsOlderThan(database, cutoffMillis) + assertEquals(5, deleted) + assertEquals(5, UploadService.getReadyUploads(database).size) + + // Rows whose CREATED_AT is exactly at the cutoff must not be removed (strict `<` predicate). + UploadService.insertUpload(database, uploadJson(cutoffMillis), uploadSettings) + assertEquals(0, UploadService.deleteUploadsOlderThan(database, cutoffMillis)) + assertEquals(6, UploadService.getReadyUploads(database).size) + } + + @Throws(JSONException::class) + private fun uploadJson(timestampMillis: Long): JSONObject = JSONObject() + .put(Constants.MessageKey.TIMESTAMP, timestampMillis) + .put("payload", "test") +} diff --git a/android-core/src/main/java/com/mparticle/internal/UploadHandler.java b/android-core/src/main/java/com/mparticle/internal/UploadHandler.java index 5bfe5cea6..b1f3d9518 100644 --- a/android-core/src/main/java/com/mparticle/internal/UploadHandler.java +++ b/android-core/src/main/java/com/mparticle/internal/UploadHandler.java @@ -235,7 +235,9 @@ protected void upload() { * Run an age-based retention sweep across persisted events, batches, and sessions at most * once every {@link #PERSISTENCE_CLEANUP_INTERVAL_MILLIS}. When the consumer has not * configured {@link com.mparticle.MParticleOptions.Builder#persistenceMaxAgeSeconds(int)}, - * the default 90-day window is used. + * the default 90-day window is used. The throttle timestamp is only advanced on a + * successful sweep so that transient failures (for example a locked database) can be + * retried on the next upload cycle rather than deferred for 24 hours. * * @param nowMillis current time in unix-epoch milliseconds */ @@ -243,6 +245,9 @@ void maybePrunePersistedRecords(long nowMillis) { if (nowMillis - mLastPersistenceCleanupMillis < PERSISTENCE_CLEANUP_INTERVAL_MILLIS) { return; } + if (mParticleDBManager == null) { + return; + } try { Integer configured = mConfigManager == null ? null : mConfigManager.getPersistenceMaxAgeSeconds(); long maxAgeMillis = (configured == null) @@ -250,10 +255,9 @@ void maybePrunePersistedRecords(long nowMillis) { : configured.longValue() * 1000L; long cutoffMillis = nowMillis - maxAgeMillis; mParticleDBManager.deleteRecordsOlderThan(cutoffMillis); + mLastPersistenceCleanupMillis = nowMillis; } catch (Exception e) { Logger.warning(e, "Failed to prune persisted records by age."); - } finally { - mLastPersistenceCleanupMillis = nowMillis; } } diff --git a/android-core/src/test/kotlin/com/mparticle/internal/UploadHandlerTest.kt b/android-core/src/test/kotlin/com/mparticle/internal/UploadHandlerTest.kt index ff4640404..52e4e0fe0 100644 --- a/android-core/src/test/kotlin/com/mparticle/internal/UploadHandlerTest.kt +++ b/android-core/src/test/kotlin/com/mparticle/internal/UploadHandlerTest.kt @@ -130,6 +130,30 @@ class UploadHandlerTest { handler.uploadMessage(522, "", mConfigManager.uploadSettings) } + @Test + fun testMaybePrunePersistedRecordsRetriesAfterFailure() { + val db = handler.mParticleDBManager + + // Phase 1 - deleteRecordsOlderThan throws; throttle must NOT be armed. + Mockito.doThrow(RuntimeException("boom")).`when`(db).deleteRecordsOlderThan(Mockito.anyLong()) + val t1 = 1_000_000_000_000L + handler.maybePrunePersistedRecords(t1) + Mockito.verify(db, Mockito.times(1)).deleteRecordsOlderThan(Mockito.anyLong()) + + // Phase 2 - a minute later the sweep retries because the throttle was not armed. + handler.maybePrunePersistedRecords(t1 + 60_000L) + Mockito.verify(db, Mockito.times(2)).deleteRecordsOlderThan(Mockito.anyLong()) + + // Phase 3 - successful sweep arms the throttle. + Mockito.doNothing().`when`(db).deleteRecordsOlderThan(Mockito.anyLong()) + handler.maybePrunePersistedRecords(t1 + 120_000L) + Mockito.verify(db, Mockito.times(3)).deleteRecordsOlderThan(Mockito.anyLong()) + + // Phase 4 - a minute after the successful sweep the throttle short-circuits the call. + handler.maybePrunePersistedRecords(t1 + 180_000L) + Mockito.verify(db, Mockito.times(3)).deleteRecordsOlderThan(Mockito.anyLong()) + } + @Test @Throws(Exception::class) fun testGetDeviceInfo() { From c588b6758eafccdda46746514a9277b40e428101 Mon Sep 17 00:00:00 2001 From: Junias Date: Mon, 20 Apr 2026 15:57:56 -0400 Subject: [PATCH 3/4] TRIAGE-608: Make properties @VisibleForTesting --- .../com/mparticle/internal/UploadHandler.java | 4 + .../mparticle/internal/UploadHandlerTest.kt | 79 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/android-core/src/main/java/com/mparticle/internal/UploadHandler.java b/android-core/src/main/java/com/mparticle/internal/UploadHandler.java index b1f3d9518..9cf64d62d 100644 --- a/android-core/src/main/java/com/mparticle/internal/UploadHandler.java +++ b/android-core/src/main/java/com/mparticle/internal/UploadHandler.java @@ -8,6 +8,7 @@ import android.os.Message; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.mparticle.MParticle; import com.mparticle.audience.AudienceResponse; @@ -61,12 +62,14 @@ public class UploadHandler extends BaseHandler { * {@link com.mparticle.MParticleOptions.Builder#persistenceMaxAgeSeconds(int)} is not set. * Matches the iOS SDK's 90-day default. */ + @VisibleForTesting static final long DEFAULT_PERSISTENCE_MAX_AGE_MILLIS = 90L * 24L * 60L * 60L * 1000L; /** * Minimum interval between age-based persistence sweeps, matching the iOS SDK's 24 hour * throttle on {@code cleanUp}. */ + @VisibleForTesting static final long PERSISTENCE_CLEANUP_INTERVAL_MILLIS = 24L * 60L * 60L * 1000L; /** @@ -241,6 +244,7 @@ protected void upload() { * * @param nowMillis current time in unix-epoch milliseconds */ + @VisibleForTesting void maybePrunePersistedRecords(long nowMillis) { if (nowMillis - mLastPersistenceCleanupMillis < PERSISTENCE_CLEANUP_INTERVAL_MILLIS) { return; diff --git a/android-core/src/test/kotlin/com/mparticle/internal/UploadHandlerTest.kt b/android-core/src/test/kotlin/com/mparticle/internal/UploadHandlerTest.kt index 52e4e0fe0..12e478412 100644 --- a/android-core/src/test/kotlin/com/mparticle/internal/UploadHandlerTest.kt +++ b/android-core/src/test/kotlin/com/mparticle/internal/UploadHandlerTest.kt @@ -130,6 +130,85 @@ class UploadHandlerTest { handler.uploadMessage(522, "", mConfigManager.uploadSettings) } + @Test + fun testMaybePrunePersistedRecordsUsesDefaultMaxAgeWhenUnconfigured() { + val db = Mockito.mock(MParticleDBManager::class.java) + val config = Mockito.mock(ConfigManager::class.java) + // Mockito's default Answer returns Integer.valueOf(0) for Integer wrapper return + // types, so stub null explicitly to model the "consumer did not configure a value" + // path. The production code must then fall back to DEFAULT_PERSISTENCE_MAX_AGE_MILLIS. + Mockito.doReturn(null).`when`(config).persistenceMaxAgeSeconds + val uploadHandler = + UploadHandler( + MockContext(), + config, + Mockito.mock(AppStateManager::class.java), + Mockito.mock(MessageManager::class.java), + db, + Mockito.mock(KitFrameworkWrapper::class.java), + ) + val capturedCutoff = java.util.concurrent.atomic.AtomicLong(Long.MIN_VALUE) + Mockito.doAnswer { invocation -> + capturedCutoff.set(invocation.getArgument(0)) + null + }.`when`(db).deleteRecordsOlderThan(Mockito.anyLong()) + + val now = 1_700_000_000_000L + uploadHandler.maybePrunePersistedRecords(now) + + Mockito.verify(db, Mockito.times(1)).deleteRecordsOlderThan(Mockito.anyLong()) + Assert.assertEquals( + now - UploadHandler.DEFAULT_PERSISTENCE_MAX_AGE_MILLIS, + capturedCutoff.get(), + ) + } + + @Test + fun testMaybePrunePersistedRecordsUsesConfiguredMaxAge() { + val db = Mockito.mock(MParticleDBManager::class.java) + val config = Mockito.mock(ConfigManager::class.java) + // 1 hour retention window -> cutoff should be exactly now - 3_600_000 ms. + // Guards the seconds-to-millis conversion in maybePrunePersistedRecords. + Mockito.`when`(config.persistenceMaxAgeSeconds).thenReturn(3600) + val uploadHandler = + UploadHandler( + MockContext(), + config, + Mockito.mock(AppStateManager::class.java), + Mockito.mock(MessageManager::class.java), + db, + Mockito.mock(KitFrameworkWrapper::class.java), + ) + val capturedCutoff = java.util.concurrent.atomic.AtomicLong(Long.MIN_VALUE) + Mockito.doAnswer { invocation -> + capturedCutoff.set(invocation.getArgument(0)) + null + }.`when`(db).deleteRecordsOlderThan(Mockito.anyLong()) + + val now = 1_700_000_000_000L + uploadHandler.maybePrunePersistedRecords(now) + + Assert.assertEquals(now - 3_600_000L, capturedCutoff.get()) + } + + @Test + fun testMaybePrunePersistedRecordsHonorsTwentyFourHourThrottle() { + val db = handler.mParticleDBManager + val t0 = 1_000_000_000_000L + + // First call always runs the sweep. + handler.maybePrunePersistedRecords(t0) + Mockito.verify(db, Mockito.times(1)).deleteRecordsOlderThan(Mockito.anyLong()) + + // One millisecond before the throttle interval expires - still a no-op. + handler.maybePrunePersistedRecords(t0 + UploadHandler.PERSISTENCE_CLEANUP_INTERVAL_MILLIS - 1L) + Mockito.verify(db, Mockito.times(1)).deleteRecordsOlderThan(Mockito.anyLong()) + + // Exactly at the throttle boundary - the sweep runs again. + handler.maybePrunePersistedRecords(t0 + UploadHandler.PERSISTENCE_CLEANUP_INTERVAL_MILLIS) + Mockito.verify(db, Mockito.times(2)).deleteRecordsOlderThan(Mockito.anyLong()) + } + @Test fun testMaybePrunePersistedRecordsRetriesAfterFailure() { val db = handler.mParticleDBManager From efe31bbd35dfdb3e1739d1ece4ec6911832bd48d Mon Sep 17 00:00:00 2001 From: Junias Date: Tue, 21 Apr 2026 12:38:32 -0400 Subject: [PATCH 4/4] TRIAGE-608: Fix retry-on-failure throttle logic --- .../com/mparticle/internal/UploadHandler.java | 15 ++++++--------- .../database/services/MParticleDBManager.java | 13 ++++++++++--- .../com/mparticle/internal/UploadHandlerTest.kt | 14 +++++++++----- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/android-core/src/main/java/com/mparticle/internal/UploadHandler.java b/android-core/src/main/java/com/mparticle/internal/UploadHandler.java index 9cf64d62d..2ce074c28 100644 --- a/android-core/src/main/java/com/mparticle/internal/UploadHandler.java +++ b/android-core/src/main/java/com/mparticle/internal/UploadHandler.java @@ -252,16 +252,13 @@ void maybePrunePersistedRecords(long nowMillis) { if (mParticleDBManager == null) { return; } - try { - Integer configured = mConfigManager == null ? null : mConfigManager.getPersistenceMaxAgeSeconds(); - long maxAgeMillis = (configured == null) - ? DEFAULT_PERSISTENCE_MAX_AGE_MILLIS - : configured.longValue() * 1000L; - long cutoffMillis = nowMillis - maxAgeMillis; - mParticleDBManager.deleteRecordsOlderThan(cutoffMillis); + Integer configured = mConfigManager == null ? null : mConfigManager.getPersistenceMaxAgeSeconds(); + long maxAgeMillis = (configured == null) + ? DEFAULT_PERSISTENCE_MAX_AGE_MILLIS + : configured.longValue() * 1000L; + long cutoffMillis = nowMillis - maxAgeMillis; + if (mParticleDBManager.deleteRecordsOlderThan(cutoffMillis)) { mLastPersistenceCleanupMillis = nowMillis; - } catch (Exception e) { - Logger.warning(e, "Failed to prune persisted records by age."); } } diff --git a/android-core/src/main/java/com/mparticle/internal/database/services/MParticleDBManager.java b/android-core/src/main/java/com/mparticle/internal/database/services/MParticleDBManager.java index 8eb5d8594..9f46908d9 100644 --- a/android-core/src/main/java/com/mparticle/internal/database/services/MParticleDBManager.java +++ b/android-core/src/main/java/com/mparticle/internal/database/services/MParticleDBManager.java @@ -7,6 +7,7 @@ import android.os.Handler; import android.os.Looper; +import androidx.annotation.CheckResult; import androidx.annotation.Nullable; import com.mparticle.MParticle; @@ -211,12 +212,16 @@ public void deleteMessagesAndSessions(String currentSessionId) { *

* Deletes any messages and uploads whose {@code CREATED_AT} is strictly less than * {@code cutoffMillis}, and any sessions whose {@code END_TIME} is strictly less than - * {@code cutoffMillis}. Mirrors the behaviour of the iOS SDK's - * {@code MPPersistenceController.deleteRecordsOlderThan:}. + * {@code cutoffMillis}. + * {@code MPPersistenceController.deleteRecordsOlderThan:}, but reports success so + * callers can decide whether to arm retry/throttle state. * * @param cutoffMillis the unix-epoch millisecond cutoff; rows older than this are removed + * @return {@code true} if the transaction committed successfully, {@code false} if any + * exception was caught (and logged) during the sweep */ - public void deleteRecordsOlderThan(long cutoffMillis) { + @CheckResult + public boolean deleteRecordsOlderThan(long cutoffMillis) { MPDatabase db = getDatabase(); try { db.beginTransaction(); @@ -224,8 +229,10 @@ public void deleteRecordsOlderThan(long cutoffMillis) { UploadService.deleteUploadsOlderThan(db, cutoffMillis); SessionService.deleteSessionsOlderThan(db, cutoffMillis); db.setTransactionSuccessful(); + return true; } catch (Exception e) { Logger.warning(e, "Error pruning persisted records older than " + cutoffMillis + " ms."); + return false; } finally { db.endTransaction(); } diff --git a/android-core/src/test/kotlin/com/mparticle/internal/UploadHandlerTest.kt b/android-core/src/test/kotlin/com/mparticle/internal/UploadHandlerTest.kt index 12e478412..3c64cadb8 100644 --- a/android-core/src/test/kotlin/com/mparticle/internal/UploadHandlerTest.kt +++ b/android-core/src/test/kotlin/com/mparticle/internal/UploadHandlerTest.kt @@ -150,7 +150,7 @@ class UploadHandlerTest { val capturedCutoff = java.util.concurrent.atomic.AtomicLong(Long.MIN_VALUE) Mockito.doAnswer { invocation -> capturedCutoff.set(invocation.getArgument(0)) - null + true }.`when`(db).deleteRecordsOlderThan(Mockito.anyLong()) val now = 1_700_000_000_000L @@ -182,7 +182,7 @@ class UploadHandlerTest { val capturedCutoff = java.util.concurrent.atomic.AtomicLong(Long.MIN_VALUE) Mockito.doAnswer { invocation -> capturedCutoff.set(invocation.getArgument(0)) - null + true }.`when`(db).deleteRecordsOlderThan(Mockito.anyLong()) val now = 1_700_000_000_000L @@ -194,6 +194,8 @@ class UploadHandlerTest { @Test fun testMaybePrunePersistedRecordsHonorsTwentyFourHourThrottle() { val db = handler.mParticleDBManager + // The sweep must succeed so the throttle arms; otherwise every call retries. + Mockito.`when`(db.deleteRecordsOlderThan(Mockito.anyLong())).thenReturn(true) val t0 = 1_000_000_000_000L // First call always runs the sweep. @@ -213,8 +215,10 @@ class UploadHandlerTest { fun testMaybePrunePersistedRecordsRetriesAfterFailure() { val db = handler.mParticleDBManager - // Phase 1 - deleteRecordsOlderThan throws; throttle must NOT be armed. - Mockito.doThrow(RuntimeException("boom")).`when`(db).deleteRecordsOlderThan(Mockito.anyLong()) + // Phase 1 - deleteRecordsOlderThan reports failure (false); throttle must NOT be armed. + // This models the real contract: MParticleDBManager.deleteRecordsOlderThan catches + // SQL exceptions internally and signals failure via its return value. + Mockito.`when`(db.deleteRecordsOlderThan(Mockito.anyLong())).thenReturn(false) val t1 = 1_000_000_000_000L handler.maybePrunePersistedRecords(t1) Mockito.verify(db, Mockito.times(1)).deleteRecordsOlderThan(Mockito.anyLong()) @@ -224,7 +228,7 @@ class UploadHandlerTest { Mockito.verify(db, Mockito.times(2)).deleteRecordsOlderThan(Mockito.anyLong()) // Phase 3 - successful sweep arms the throttle. - Mockito.doNothing().`when`(db).deleteRecordsOlderThan(Mockito.anyLong()) + Mockito.`when`(db.deleteRecordsOlderThan(Mockito.anyLong())).thenReturn(true) handler.maybePrunePersistedRecords(t1 + 120_000L) Mockito.verify(db, Mockito.times(3)).deleteRecordsOlderThan(Mockito.anyLong())