Skip to content

Commit b08d464

Browse files
authored
Merge pull request #3673 from DataDog/jmoskovich/fix-resourceDataStoreManager-time
Fix resource hash cache expiration time
2 parents 1ba612d + 231468b commit b08d464

3 files changed

Lines changed: 328 additions & 28 deletions

File tree

features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/resources/ResourceDataStoreManager.kt

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ internal class ResourceDataStoreManager(
2727
) {
2828
@Suppress("UnsafeThirdPartyFunctionCall") // map is initialized empty
2929
private val knownResources = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
30-
private val storedLastUpdateDateNs = AtomicLong(featureSdkCore.timeProvider.getDeviceElapsedTimeNanos())
30+
private val storedLastUpdateTimestampMs = AtomicLong(featureSdkCore.timeProvider.getDeviceTimestampMillis())
3131
private val isInitialized = AtomicBoolean(false) // has init finished executing its async actions
3232

3333
init {
@@ -40,10 +40,12 @@ internal class ResourceDataStoreManager(
4040
return@lambda
4141
}
4242

43-
val lastUpdateDateNs = storedData.lastUpdateDateNs.toLong()
43+
val lastUpdateTimestampMs = storedData.lastUpdateDateNs.toLong()
4444
val storedHashes = storedData.resourceHashes
4545

46-
if (didDataStoreExpire(lastUpdateDateNs)) {
46+
if (storedEntry.versionCode != DATASTORE_VERSION ||
47+
didDataStoreExpire(lastUpdateTimestampMs)
48+
) {
4749
deleteStoredHashesEntry(
4850
callback = object : DataStoreWriteCallback {
4951
override fun onSuccess() {
@@ -56,7 +58,7 @@ internal class ResourceDataStoreManager(
5658
}
5759
)
5860
} else {
59-
storedLastUpdateDateNs.set(lastUpdateDateNs)
61+
storedLastUpdateTimestampMs.set(lastUpdateTimestampMs)
6062
knownResources.addAll(storedHashes)
6163
finishedInitializingManager()
6264
}
@@ -85,8 +87,14 @@ internal class ResourceDataStoreManager(
8587
}
8688

8789
private fun writeResourcesToStore() {
90+
// Refresh on every write, otherwise the persisted timestamp only reflects when the store
91+
// was last loaded, not when it was last used, causing premature expiration on next init.
92+
storedLastUpdateTimestampMs.set(featureSdkCore.timeProvider.getDeviceTimestampMillis())
93+
8894
val data = ResourceHashesEntry(
89-
lastUpdateDateNs = storedLastUpdateDateNs,
95+
// This generated model property keeps its legacy name for persisted-data compatibility.
96+
// The datastore version identifies version 0 values as nanoseconds and version 1 as milliseconds.
97+
lastUpdateDateNs = storedLastUpdateTimestampMs.get(),
9098
resourceHashes = knownResources.toList()
9199
)
92100

@@ -95,6 +103,7 @@ internal class ResourceDataStoreManager(
95103
)?.dataStore?.setValue(
96104
data = data,
97105
key = DATASTORE_HASHES_ENTRY_NAME,
106+
version = DATASTORE_VERSION,
98107
serializer = resourceHashesSerializer
99108
)
100109
}
@@ -128,14 +137,19 @@ internal class ResourceDataStoreManager(
128137
callback = callback
129138
)
130139

131-
private fun didDataStoreExpire(lastUpdateDate: Long): Boolean =
132-
featureSdkCore.timeProvider.getDeviceElapsedTimeNanos() - lastUpdateDate > DATASTORE_EXPIRATION_NS
140+
private fun didDataStoreExpire(lastUpdateTimestampMs: Long): Boolean {
141+
val currentTimestampMs = featureSdkCore.timeProvider.getDeviceTimestampMillis()
142+
143+
return lastUpdateTimestampMs <= 0 ||
144+
lastUpdateTimestampMs > currentTimestampMs ||
145+
currentTimestampMs - lastUpdateTimestampMs > DATASTORE_EXPIRATION_MS
146+
}
133147

134148
// endregion
135149

136150
internal companion object {
137-
internal const val DATASTORE_EXPIRATION_NS =
138-
DateUtils.DAY_IN_MILLIS * 30 * 1000 * 1000 // 30 days in nanoseconds
151+
internal const val DATASTORE_EXPIRATION_MS = DateUtils.DAY_IN_MILLIS * 30
139152
internal const val DATASTORE_HASHES_ENTRY_NAME = "resource-hash-store"
153+
internal const val DATASTORE_VERSION = 1
140154
}
141155
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.sessionreplay.internal.resources
8+
9+
import com.datadog.android.api.InternalLogger
10+
import com.datadog.android.api.feature.Feature
11+
import com.datadog.android.api.feature.FeatureScope
12+
import com.datadog.android.api.feature.FeatureSdkCore
13+
import com.datadog.android.api.storage.datastore.DataStoreHandler
14+
import com.datadog.android.api.storage.datastore.DataStoreReadCallback
15+
import com.datadog.android.api.storage.datastore.DataStoreWriteCallback
16+
import com.datadog.android.core.internal.persistence.Deserializer
17+
import com.datadog.android.core.persistence.Serializer
18+
import com.datadog.android.core.persistence.datastore.DataStoreContent
19+
import com.datadog.android.internal.time.TimeProvider
20+
import com.datadog.android.sessionreplay.internal.resources.ResourceDataStoreManager.Companion.DATASTORE_EXPIRATION_MS
21+
import com.datadog.android.sessionreplay.internal.resources.ResourceDataStoreManager.Companion.DATASTORE_VERSION
22+
import org.assertj.core.api.Assertions.assertThat
23+
import org.junit.jupiter.api.BeforeEach
24+
import org.junit.jupiter.api.Test
25+
import org.junit.jupiter.api.extension.ExtendWith
26+
import org.mockito.Mock
27+
import org.mockito.junit.jupiter.MockitoExtension
28+
import org.mockito.kotlin.never
29+
import org.mockito.kotlin.verify
30+
import org.mockito.kotlin.whenever
31+
32+
@ExtendWith(MockitoExtension::class)
33+
internal class ResourceDataStoreManagerMigrationTest {
34+
35+
@Mock
36+
lateinit var mockFeatureSdkCore: FeatureSdkCore
37+
38+
@Mock
39+
lateinit var mockFeatureScope: FeatureScope
40+
41+
@Mock
42+
lateinit var mockTimeProvider: TimeProvider
43+
44+
@Mock
45+
lateinit var mockInternalLogger: InternalLogger
46+
47+
private lateinit var resourceHashesSerializer: ResourceHashesEntrySerializer
48+
private lateinit var resourceHashesDeserializer: ResourceHashesEntryDeserializer
49+
private lateinit var dataStore: InMemoryDataStoreHandler
50+
51+
@BeforeEach
52+
fun setup() {
53+
resourceHashesSerializer = ResourceHashesEntrySerializer()
54+
resourceHashesDeserializer = ResourceHashesEntryDeserializer(mockInternalLogger)
55+
dataStore = InMemoryDataStoreHandler(
56+
entry = StoredEntry(
57+
version = LEGACY_DATASTORE_VERSION,
58+
serializedData = LEGACY_DATASTORE_JSON
59+
)
60+
)
61+
62+
whenever(mockFeatureSdkCore.timeProvider).thenReturn(mockTimeProvider)
63+
whenever(mockTimeProvider.getDeviceTimestampMillis()).thenReturn(CURRENT_TIMESTAMP_MS)
64+
whenever(mockFeatureSdkCore.getFeature(Feature.SESSION_REPLAY_RESOURCES_FEATURE_NAME))
65+
.thenReturn(mockFeatureScope)
66+
whenever(mockFeatureScope.dataStore).thenReturn(dataStore)
67+
}
68+
69+
@Test
70+
fun `M migrate persisted data W init { legacy datastore entry }`() {
71+
// When
72+
val testedManager = createManager()
73+
74+
// Then
75+
assertThat(testedManager.isReady()).isTrue()
76+
assertThat(testedManager.isPreviouslySentResource(LEGACY_RESOURCE_HASH)).isFalse()
77+
assertThat(dataStore.entry).isNull()
78+
assertThat(dataStore.removeCount).isEqualTo(1)
79+
80+
// When
81+
testedManager.cacheResourceHash(NEW_RESOURCE_HASH)
82+
83+
// Then
84+
val migratedEntry = checkNotNull(dataStore.entry)
85+
val migratedData = resourceHashesDeserializer.deserialize(migratedEntry.serializedData)
86+
87+
assertThat(migratedEntry.version).isEqualTo(DATASTORE_VERSION)
88+
assertThat(migratedData?.lastUpdateDateNs?.toLong()).isEqualTo(CURRENT_TIMESTAMP_MS)
89+
assertThat(migratedData?.resourceHashes).containsExactly(NEW_RESOURCE_HASH)
90+
91+
// When
92+
val recreatedManager = createManager()
93+
94+
// Then
95+
assertThat(recreatedManager.isReady()).isTrue()
96+
assertThat(recreatedManager.isPreviouslySentResource(NEW_RESOURCE_HASH)).isTrue()
97+
assertThat(dataStore.removeCount).isEqualTo(1)
98+
99+
// When
100+
whenever(mockTimeProvider.getDeviceTimestampMillis())
101+
.thenReturn(CURRENT_TIMESTAMP_MS + DATASTORE_EXPIRATION_MS + 1)
102+
val expiredManager = createManager()
103+
104+
// Then
105+
assertThat(expiredManager.isReady()).isTrue()
106+
assertThat(expiredManager.isPreviouslySentResource(NEW_RESOURCE_HASH)).isFalse()
107+
assertThat(dataStore.entry).isNull()
108+
assertThat(dataStore.removeCount).isEqualTo(2)
109+
verify(mockTimeProvider, never()).getDeviceElapsedTimeNanos()
110+
}
111+
112+
private fun createManager(): ResourceDataStoreManager {
113+
return ResourceDataStoreManager(
114+
featureSdkCore = mockFeatureSdkCore,
115+
resourceHashesSerializer = resourceHashesSerializer,
116+
resourceHashesDeserializer = resourceHashesDeserializer
117+
)
118+
}
119+
120+
private data class StoredEntry(
121+
val version: Int,
122+
val serializedData: String
123+
)
124+
125+
private class InMemoryDataStoreHandler(
126+
var entry: StoredEntry?
127+
) : DataStoreHandler {
128+
129+
var removeCount: Int = 0
130+
private set
131+
132+
override fun <T : Any> setValue(
133+
key: String,
134+
data: T,
135+
version: Int,
136+
callback: DataStoreWriteCallback?,
137+
serializer: Serializer<T>
138+
) {
139+
val serializedData = serializer.serialize(data)
140+
if (serializedData == null) {
141+
callback?.onFailure()
142+
} else {
143+
entry = StoredEntry(version, serializedData)
144+
callback?.onSuccess()
145+
}
146+
}
147+
148+
override fun <T : Any> value(
149+
key: String,
150+
version: Int?,
151+
callback: DataStoreReadCallback<T>,
152+
deserializer: Deserializer<String, T>
153+
) {
154+
val storedEntry = entry
155+
if (storedEntry == null || version != null && version != storedEntry.version) {
156+
callback.onSuccess(null)
157+
} else {
158+
callback.onSuccess(
159+
DataStoreContent(
160+
versionCode = storedEntry.version,
161+
data = deserializer.deserialize(storedEntry.serializedData)
162+
)
163+
)
164+
}
165+
}
166+
167+
override fun removeValue(key: String, callback: DataStoreWriteCallback?) {
168+
removeCount++
169+
entry = null
170+
callback?.onSuccess()
171+
}
172+
173+
override fun clearAllData() {
174+
entry = null
175+
}
176+
}
177+
178+
private companion object {
179+
private const val LEGACY_DATASTORE_VERSION = 0
180+
private const val LEGACY_RESOURCE_HASH = "legacy-resource-hash"
181+
private const val NEW_RESOURCE_HASH = "new-resource-hash"
182+
private const val CURRENT_TIMESTAMP_MS = 1_800_000_000_000L
183+
private const val LEGACY_DATASTORE_JSON = """
184+
{
185+
"last_update_date_ns": 86400000000000,
186+
"resource_hashes": ["legacy-resource-hash"]
187+
}
188+
"""
189+
}
190+
}

0 commit comments

Comments
 (0)