Skip to content

Commit 695e6ef

Browse files
committed
fix(flags): dedupe exposures by latest assignment
1 parent e0d2f78 commit 695e6ef

2 files changed

Lines changed: 57 additions & 21 deletions

File tree

features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessor.kt

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,49 +18,51 @@ internal class ExposureEventsProcessor(private val writer: RecordWriter, private
1818

1919
private data class CacheKey(
2020
val targetingKey: String,
21-
val flagName: String,
21+
val flagName: String
22+
)
23+
24+
private data class CacheValue(
2225
val allocationKey: String,
2326
val variationKey: String
2427
)
2528

2629
@Suppress("UnsafeThirdPartyFunctionCall") // maxSize > 0
27-
private val exposuresSentCache = object : LruCache<CacheKey, Boolean>(MAX_CACHE_SIZE_BYTES) {
28-
override fun sizeOf(key: CacheKey, value: Boolean): Int {
30+
private val exposuresSentCache = object : LruCache<CacheKey, CacheValue>(MAX_CACHE_SIZE_BYTES) {
31+
override fun sizeOf(key: CacheKey, value: CacheValue): Int {
2932
// Calculate approximate memory footprint of the cache entry
3033
// String overhead: ~40 bytes + (2 bytes per character for UTF-16)
31-
// Object overhead for CacheKey: ~16 bytes
32-
// Boolean: ~1 byte
34+
// Object overhead for CacheKey and CacheValue: ~16 bytes each
3335
val keySize = OBJECT_OVERHEAD +
3436
(STRING_OVERHEAD + key.targetingKey.length * CHAR_SIZE) +
35-
(STRING_OVERHEAD + key.flagName.length * CHAR_SIZE) +
36-
(STRING_OVERHEAD + key.allocationKey.length * CHAR_SIZE) +
37-
(STRING_OVERHEAD + key.variationKey.length * CHAR_SIZE)
38-
val valueSize = BOOLEAN_SIZE
37+
(STRING_OVERHEAD + key.flagName.length * CHAR_SIZE)
38+
val valueSize = OBJECT_OVERHEAD +
39+
(STRING_OVERHEAD + value.allocationKey.length * CHAR_SIZE) +
40+
(STRING_OVERHEAD + value.variationKey.length * CHAR_SIZE)
3941
return keySize + valueSize
4042
}
4143
}
4244

4345
override fun processEvent(flagName: String, context: EvaluationContext, data: UnparsedFlag) {
4446
val cacheKey = CacheKey(
4547
targetingKey = context.targetingKey,
46-
flagName = flagName,
48+
flagName = flagName
49+
)
50+
val cacheValue = CacheValue(
4751
allocationKey = data.allocationKey,
4852
variationKey = data.variationKey
4953
)
5054

51-
// Atomically check and mark to prevent duplicate writes
52-
// Only write to cache on first access to avoid refreshing LRU position
53-
val isFirstTime = synchronized(exposuresSentCache) {
54-
val alreadySent = exposuresSentCache[cacheKey]
55-
if (alreadySent == null) {
56-
exposuresSentCache.put(cacheKey, true)
55+
val shouldWrite = synchronized(exposuresSentCache) {
56+
val lastSentValue = exposuresSentCache[cacheKey]
57+
if (lastSentValue != cacheValue) {
58+
exposuresSentCache.put(cacheKey, cacheValue)
5759
true
5860
} else {
5961
false
6062
}
6163
}
6264

63-
if (isFirstTime) {
65+
if (shouldWrite) {
6466
val event = buildExposureEvent(flagName, context, data)
6567
writeExposureEvent(event)
6668
}
@@ -87,13 +89,14 @@ internal class ExposureEventsProcessor(private val writer: RecordWriter, private
8789
}
8890

8991
companion object {
90-
// Maximum cache size in bytes (4MB)
92+
// Maximum cache size in bytes (4MB). The expected high-water mark is
93+
// two subjects with 2,500 flags each, which is about 2.8-3.8MB when
94+
// identifiers average 200-300 combined characters per cache entry.
9195
private const val MAX_CACHE_SIZE_BYTES = 4 * 1024 * 1024 // 4MB
9296

9397
// Memory overhead constants for size calculation
9498
private const val OBJECT_OVERHEAD = 16 // bytes for object header
9599
private const val STRING_OVERHEAD = 40 // bytes for String object overhead
96100
private const val CHAR_SIZE = 2 // bytes per character (UTF-16)
97-
private const val BOOLEAN_SIZE = 1 // byte
98101
}
99102
}

features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/ExposureEventsProcessorTest.kt

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,39 @@ internal class ExposureEventsProcessorTest {
118118
verify(mockRecordWriter).write(any())
119119
}
120120

121+
@Test
122+
fun `M process exposure again W processEvent() { assignment cycles back }`() {
123+
// Given
124+
val fakeContext = EvaluationContext(targetingKey = fakeTargetingKey)
125+
val flagA = fakeFlag.copy(
126+
allocationKey = "allocation-a",
127+
variationKey = "variation-a"
128+
)
129+
val flagB = fakeFlag.copy(
130+
allocationKey = "allocation-b",
131+
variationKey = "variation-b"
132+
)
133+
134+
// When
135+
testedProcessor.processEvent(fakeFlagName, fakeContext, flagA)
136+
testedProcessor.processEvent(fakeFlagName, fakeContext, flagB)
137+
testedProcessor.processEvent(fakeFlagName, fakeContext, flagA)
138+
139+
// Then
140+
val eventCaptor = argumentCaptor<ExposureEvent>()
141+
verify(mockRecordWriter, times(3)).write(eventCaptor.capture())
142+
assertThat(eventCaptor.allValues.map { it.allocation.key }).containsExactly(
143+
"allocation-a",
144+
"allocation-b",
145+
"allocation-a"
146+
)
147+
assertThat(eventCaptor.allValues.map { it.variant.key }).containsExactly(
148+
"variation-a",
149+
"variation-b",
150+
"variation-a"
151+
)
152+
}
153+
121154
@Test
122155
fun `M process different exposures W processEvent() { different flag names }`(forge: Forge) {
123156
// Given
@@ -397,10 +430,10 @@ internal class ExposureEventsProcessorTest {
397430
testedProcessor.processEvent("flag1", context2, flag1) // Different context
398431
testedProcessor.processEvent("flag2", context1, flag1) // Different flag name
399432
testedProcessor.processEvent("flag1", context1, flag2) // Different flag data
400-
testedProcessor.processEvent("flag1", context1, flag1) // Duplicate again
433+
testedProcessor.processEvent("flag1", context1, flag1) // Assignment changed back
401434

402435
// Then
403-
verify(mockRecordWriter, times(4)).write(any())
436+
verify(mockRecordWriter, times(5)).write(any())
404437
}
405438

406439
// endregion

0 commit comments

Comments
 (0)