Skip to content

Commit ca88322

Browse files
authored
feat: Add max persistence age override option [TRIAGE-608] (#699)
* TRIAGE-608: Add max persistence age override option * TRIAGE-608: Update throttl ts on success + tests * TRIAGE-608: Make properties @VisibleForTesting * TRIAGE-608: Fix retry-on-failure throttle logic
1 parent 20f723f commit ca88322

12 files changed

Lines changed: 515 additions & 0 deletions

File tree

android-core/src/androidTest/kotlin/com.mparticle/MParticleOptionsTest.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,46 @@ class MParticleOptionsTest : BaseAbstractTest() {
694694
Assert.assertNull(options.configMaxAge)
695695
}
696696

697+
@Test
698+
fun testPersistenceMaxAgeSeconds() {
699+
// nothing set, should return null (SDK will fall back to the 90-day default)
700+
var options =
701+
MParticleOptions
702+
.builder(mContext)
703+
.credentials("key", "secret")
704+
.build()
705+
Assert.assertNull(options.persistenceMaxAgeSeconds)
706+
707+
// positive number should be preserved
708+
val testValue = Math.abs(ran.nextInt()) + 1
709+
options =
710+
MParticleOptions
711+
.builder(mContext)
712+
.credentials("key", "secret")
713+
.persistenceMaxAgeSeconds(testValue)
714+
.build()
715+
Assert.assertEquals(testValue, options.persistenceMaxAgeSeconds)
716+
717+
// zero is non-positive and should be rejected (differs from configMaxAgeSeconds which
718+
// accepts zero as "always stale") - mirrors iOS SDK behaviour
719+
options =
720+
MParticleOptions
721+
.builder(mContext)
722+
.credentials("key", "secret")
723+
.persistenceMaxAgeSeconds(0)
724+
.build()
725+
Assert.assertNull(options.persistenceMaxAgeSeconds)
726+
727+
// negative numbers should be rejected
728+
options =
729+
MParticleOptions
730+
.builder(mContext)
731+
.credentials("key", "secret")
732+
.persistenceMaxAgeSeconds(-5)
733+
.build()
734+
Assert.assertNull(options.persistenceMaxAgeSeconds)
735+
}
736+
697737
@Test
698738
fun testAndroidIdLogMessage() {
699739
val infoLogs = ArrayList<String?>()

android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/MessageServiceTest.kt

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,72 @@ class MessageServiceTest : BaseMPServiceTest() {
400400
Assert.assertEquals(MessageService.getMessagesForUpload(database).size.toLong(), 20)
401401
}
402402

403+
@Test
404+
@Throws(JSONException::class)
405+
fun testDeleteMessagesOlderThan() {
406+
val sessionId = UUID.randomUUID().toString()
407+
val now = System.currentTimeMillis()
408+
val oneDayMillis = 24L * 60L * 60L * 1000L
409+
// Insert 5 "old" messages dated 10 days ago and 5 "recent" messages dated 1 hour ago.
410+
for (i in 0 until 5) {
411+
val oldMessage =
412+
BaseMPMessage
413+
.Builder("custom_event")
414+
.timestamp(now - 10L * oneDayMillis)
415+
.build(
416+
InternalSession().apply { mSessionID = sessionId },
417+
null,
418+
1L,
419+
)
420+
MessageService.insertMessage(database, "apiKey", oldMessage, 1L, null, null)
421+
}
422+
for (i in 0 until 5) {
423+
val recentMessage =
424+
BaseMPMessage
425+
.Builder("custom_event")
426+
.timestamp(now - 60L * 60L * 1000L)
427+
.build(
428+
InternalSession().apply { mSessionID = sessionId },
429+
null,
430+
1L,
431+
)
432+
MessageService.insertMessage(database, "apiKey", recentMessage, 1L, null, null)
433+
}
434+
Assert.assertEquals(
435+
10L,
436+
MessageService.getMessagesForUpload(database).size.toLong(),
437+
)
438+
439+
// Cut off at 7 days ago - the 5 old messages should be removed and the 5 recent kept.
440+
val cutoffMillis = now - 7L * oneDayMillis
441+
val deleted = MessageService.deleteMessagesOlderThan(database, cutoffMillis)
442+
Assert.assertEquals(5, deleted.toLong())
443+
Assert.assertEquals(
444+
5L,
445+
MessageService.getMessagesForUpload(database).size.toLong(),
446+
)
447+
448+
// Rows exactly at the cutoff must not be removed (strict `<` predicate).
449+
val exactlyAtCutoffMessage =
450+
BaseMPMessage
451+
.Builder("custom_event")
452+
.timestamp(cutoffMillis)
453+
.build(
454+
InternalSession().apply { mSessionID = sessionId },
455+
null,
456+
1L,
457+
)
458+
MessageService.insertMessage(database, "apiKey", exactlyAtCutoffMessage, 1L, null, null)
459+
Assert.assertEquals(
460+
0,
461+
MessageService.deleteMessagesOlderThan(database, cutoffMillis).toLong(),
462+
)
463+
Assert.assertEquals(
464+
6L,
465+
MessageService.getMessagesForUpload(database).size.toLong(),
466+
)
467+
}
468+
403469
private fun getMaxId(messages: List<ReadyMessage>): Int {
404470
var max = 0
405471
for (message in messages) {

android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/SessionServiceTest.kt

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.database.Cursor
44
import com.mparticle.internal.BatchId
55
import com.mparticle.internal.MessageBatch
66
import com.mparticle.internal.database.tables.SessionTable
7+
import org.json.JSONException
78
import org.json.JSONObject
89
import org.junit.Assert
910
import org.junit.Assert.assertEquals
@@ -76,6 +77,59 @@ class SessionServiceTest : BaseMPServiceTest() {
7677
}
7778
}
7879

80+
@Test
81+
@Throws(JSONException::class)
82+
fun testDeleteSessionsOlderThan() {
83+
val now = System.currentTimeMillis()
84+
val oneDayMillis = 24L * 60L * 60L * 1000L
85+
val oldEndTime = now - 10L * oneDayMillis
86+
val recentEndTime = now - 60L * 60L * 1000L
87+
88+
// Insert 5 sessions whose END_TIME is 10 days ago and 5 whose END_TIME is 1 hour ago.
89+
// insertSession seeds END_TIME = START_TIME, so we call updateSessionEndTime to model
90+
// the production flow where subsequent events advance END_TIME independently.
91+
for (i in 0 until 5) {
92+
val oldSessionId = UUID.randomUUID().toString()
93+
SessionService.insertSession(database, getMpMessage(oldSessionId), "apiKey", "{}", "{}", 1L)
94+
SessionService.updateSessionEndTime(database, oldSessionId, oldEndTime, 0)
95+
}
96+
for (i in 0 until 5) {
97+
val recentSessionId = UUID.randomUUID().toString()
98+
SessionService.insertSession(database, getMpMessage(recentSessionId), "apiKey", "{}", "{}", 1L)
99+
SessionService.updateSessionEndTime(database, recentSessionId, recentEndTime, 0)
100+
}
101+
assertEquals(10, countSessions())
102+
103+
// Cut off at 7 days ago - the 5 old sessions should be removed and the 5 recent kept.
104+
val cutoffMillis = now - 7L * oneDayMillis
105+
val deleted = SessionService.deleteSessionsOlderThan(database, cutoffMillis)
106+
assertEquals(5, deleted)
107+
assertEquals(5, countSessions())
108+
109+
// Rows whose END_TIME is exactly at the cutoff must not be removed (strict `<` predicate).
110+
val boundarySessionId = UUID.randomUUID().toString()
111+
SessionService.insertSession(database, getMpMessage(boundarySessionId), "apiKey", "{}", "{}", 1L)
112+
SessionService.updateSessionEndTime(database, boundarySessionId, cutoffMillis, 0)
113+
assertEquals(0, SessionService.deleteSessionsOlderThan(database, cutoffMillis))
114+
assertEquals(6, countSessions())
115+
}
116+
117+
private fun countSessions(): Int {
118+
var count = 0
119+
var cursor: Cursor? = null
120+
try {
121+
cursor = SessionService.getSessions(database)
122+
while (cursor.moveToNext()) {
123+
count++
124+
}
125+
} finally {
126+
if (cursor != null && !cursor.isClosed) {
127+
cursor.close()
128+
}
129+
}
130+
return count
131+
}
132+
79133
internal inner class MockMessageBatch(
80134
var id: Int,
81135
) : MessageBatch() {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.mparticle.internal.database.services
2+
3+
import com.mparticle.internal.Constants
4+
import com.mparticle.internal.database.UploadSettings
5+
import com.mparticle.networking.NetworkOptions
6+
import org.json.JSONException
7+
import org.json.JSONObject
8+
import org.junit.Assert.assertEquals
9+
import org.junit.Test
10+
11+
class UploadServiceTest : BaseMPServiceTest() {
12+
13+
@Test
14+
@Throws(JSONException::class)
15+
fun testDeleteUploadsOlderThan() {
16+
val now = System.currentTimeMillis()
17+
val oneDayMillis = 24L * 60L * 60L * 1000L
18+
val uploadSettings = UploadSettings(
19+
"apiKey",
20+
"secret",
21+
NetworkOptions.builder().build(),
22+
"",
23+
"",
24+
)
25+
26+
// Insert 5 uploads dated 10 days ago and 5 uploads dated 1 hour ago.
27+
// insertUpload reads CREATED_AT from the message's TIMESTAMP ("ct") key.
28+
for (i in 0 until 5) {
29+
UploadService.insertUpload(database, uploadJson(now - 10L * oneDayMillis), uploadSettings)
30+
}
31+
for (i in 0 until 5) {
32+
UploadService.insertUpload(database, uploadJson(now - 60L * 60L * 1000L), uploadSettings)
33+
}
34+
assertEquals(10, UploadService.getReadyUploads(database).size)
35+
36+
// Cut off at 7 days ago - the 5 old uploads should be removed and the 5 recent kept.
37+
val cutoffMillis = now - 7L * oneDayMillis
38+
val deleted = UploadService.deleteUploadsOlderThan(database, cutoffMillis)
39+
assertEquals(5, deleted)
40+
assertEquals(5, UploadService.getReadyUploads(database).size)
41+
42+
// Rows whose CREATED_AT is exactly at the cutoff must not be removed (strict `<` predicate).
43+
UploadService.insertUpload(database, uploadJson(cutoffMillis), uploadSettings)
44+
assertEquals(0, UploadService.deleteUploadsOlderThan(database, cutoffMillis))
45+
assertEquals(6, UploadService.getReadyUploads(database).size)
46+
}
47+
48+
@Throws(JSONException::class)
49+
private fun uploadJson(timestampMillis: Long): JSONObject = JSONObject()
50+
.put(Constants.MessageKey.TIMESTAMP, timestampMillis)
51+
.put("payload", "test")
52+
}

android-core/src/main/java/com/mparticle/MParticleOptions.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public class MParticleOptions {
4242
private Integer mUploadInterval = ConfigManager.DEFAULT_UPLOAD_INTERVAL; //seconds
4343
private Integer mSessionTimeout = ConfigManager.DEFAULT_SESSION_TIMEOUT_SECONDS; //seconds
4444
private Integer mConfigMaxAge = null;
45+
private Integer mPersistenceMaxAgeSeconds = null;
4546
private Boolean mUnCaughtExceptionLogging = false;
4647
private MParticle.LogLevel mLogLevel = MParticle.LogLevel.DEBUG;
4748
private AttributionListener mAttributionListener;
@@ -118,6 +119,13 @@ public MParticleOptions(@NonNull Builder builder) {
118119
this.mConfigMaxAge = builder.configMaxAge;
119120
}
120121
}
122+
if (builder.persistenceMaxAgeSeconds != null) {
123+
if (builder.persistenceMaxAgeSeconds <= 0) {
124+
Logger.warning("Persistence Max Age must be a positive number, disregarding value.");
125+
} else {
126+
this.mPersistenceMaxAgeSeconds = builder.persistenceMaxAgeSeconds;
127+
}
128+
}
121129
if (builder.unCaughtExceptionLogging != null) {
122130
this.mUnCaughtExceptionLogging = builder.unCaughtExceptionLogging;
123131
}
@@ -290,6 +298,22 @@ public Integer getConfigMaxAge() {
290298
return mConfigMaxAge;
291299
}
292300

301+
/**
302+
* The maximum threshold (in seconds) for locally persisted events, batches, and sessions.
303+
* <p>
304+
* When {@code null} (the default), records are retained for 90 days before being deleted.
305+
* Values less than or equal to zero are rejected at build time and result in the default
306+
* being used.
307+
*
308+
* @return the configured maximum persistence age in seconds, or {@code null} when the default
309+
* (90 days) applies
310+
* @see Builder#persistenceMaxAgeSeconds(int)
311+
*/
312+
@Nullable
313+
public Integer getPersistenceMaxAgeSeconds() {
314+
return mPersistenceMaxAgeSeconds;
315+
}
316+
293317
@NonNull
294318
public Boolean isUncaughtExceptionLoggingEnabled() {
295319
return mUnCaughtExceptionLogging;
@@ -403,6 +427,7 @@ public static class Builder {
403427
private Integer uploadInterval = null;
404428
private Integer sessionTimeout = null;
405429
private Integer configMaxAge = null;
430+
private Integer persistenceMaxAgeSeconds = null;
406431
private Boolean unCaughtExceptionLogging = null;
407432
MParticle.LogLevel logLevel = null;
408433
BaseIdentityTask identityTask;
@@ -622,6 +647,32 @@ public Builder configMaxAgeSeconds(int configMaxAge) {
622647
return this;
623648
}
624649

650+
/**
651+
* Set a maximum threshold for locally persisted events, batches, and sessions, in seconds.
652+
* <p>
653+
* By default, data is persisted for 90 days before being deleted to minimize data loss;
654+
* however, this can lead to excessive storage usage on some users' devices. This is
655+
* exacerbated if your app logs a large number of events, or events carrying a lot of data
656+
* (attributes, etc.).
657+
* <p>
658+
* Set a lower value (for example, 48 hours or 1 week) if you have storage usage concerns.
659+
* Alternatively, if you have data loss concerns, set a longer value than the default.
660+
* <p>
661+
* This is the Android equivalent of the iOS SDK's
662+
* {@code MParticleOptions.persistenceMaxAgeSeconds} option.
663+
*
664+
* @param persistenceMaxAgeSeconds the upper limit, in seconds, for how long persisted
665+
* data may live on disk. Must be greater than zero;
666+
* non-positive values are rejected and the default
667+
* (90 days) is used instead
668+
* @return the instance of the builder, for chaining calls
669+
*/
670+
@NonNull
671+
public Builder persistenceMaxAgeSeconds(int persistenceMaxAgeSeconds) {
672+
this.persistenceMaxAgeSeconds = persistenceMaxAgeSeconds;
673+
return this;
674+
}
675+
625676
/**
626677
* Enable or disable mParticle exception handling to automatically log events on uncaught exceptions.
627678
*

android-core/src/main/java/com/mparticle/internal/ConfigManager.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ public class ConfigManager {
106106
private String mDataplanId;
107107
private Integer mDataplanVersion;
108108
private Integer mMaxConfigAge;
109+
@Nullable
110+
private Integer mPersistenceMaxAgeSeconds;
109111
public static final int DEFAULT_CONNECTION_TIMEOUT_SECONDS = 30;
110112
public static final int MINIMUM_CONNECTION_TIMEOUT_SECONDS = 1;
111113
public static final int DEFAULT_SESSION_TIMEOUT_SECONDS = 60;
@@ -136,6 +138,17 @@ public ConfigManager(Context context) {
136138

137139
public ConfigManager(@NonNull MParticleOptions options) {
138140
this(options.getContext(), options.getEnvironment(), options.getApiKey(), options.getApiSecret(), options.getDataplanOptions(), options.getDataplanId(), options.getDataplanVersion(), options.getConfigMaxAge(), options.getConfigurationsForTarget(ConfigManager.class), options.getSideloadedKits());
141+
mPersistenceMaxAgeSeconds = options.getPersistenceMaxAgeSeconds();
142+
}
143+
144+
/**
145+
* @return the configured maximum persistence age in seconds, or {@code null} when the SDK
146+
* should fall back to its 90-day default.
147+
* @see MParticleOptions.Builder#persistenceMaxAgeSeconds(int)
148+
*/
149+
@Nullable
150+
public Integer getPersistenceMaxAgeSeconds() {
151+
return mPersistenceMaxAgeSeconds;
139152
}
140153

141154
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<Configuration<ConfigManager>> configurations, @Nullable List<SideloadedKit> sideloadedKits) {

0 commit comments

Comments
 (0)