From 35ed81b632e5a1ade5f34625f327beeb21a4ef5f Mon Sep 17 00:00:00 2001 From: gthea Date: Thu, 28 Nov 2024 17:19:09 -0300 Subject: [PATCH] Rollout cache manager (#717) --- .../android/client/SplitClientConfig.java | 4 + .../LocalhostMySegmentsStorageContainer.java | 5 + .../synchronizer/RolloutCacheManager.java | 8 + .../RolloutCacheManagerConfig.java | 29 ++++ .../synchronizer/RolloutCacheManagerImpl.java | 101 ++++++++++++ .../storage/RolloutDefinitionsCache.java | 6 + .../storage/mysegments/MySegmentsStorage.java | 8 +- .../MySegmentsStorageContainer.java | 4 +- .../MySegmentsStorageContainerImpl.java | 9 ++ .../client/storage/splits/SplitsStorage.java | 5 +- .../synchronizer/RolloutCacheManagerTest.kt | 145 ++++++++++++++++++ 11 files changed, 314 insertions(+), 10 deletions(-) create mode 100644 src/main/java/io/split/android/client/service/synchronizer/RolloutCacheManager.java create mode 100644 src/main/java/io/split/android/client/service/synchronizer/RolloutCacheManagerConfig.java create mode 100644 src/main/java/io/split/android/client/service/synchronizer/RolloutCacheManagerImpl.java create mode 100644 src/main/java/io/split/android/client/storage/RolloutDefinitionsCache.java create mode 100644 src/test/java/io/split/android/client/service/synchronizer/RolloutCacheManagerTest.kt diff --git a/src/main/java/io/split/android/client/SplitClientConfig.java b/src/main/java/io/split/android/client/SplitClientConfig.java index 15872b80b..c2eff3e9b 100644 --- a/src/main/java/io/split/android/client/SplitClientConfig.java +++ b/src/main/java/io/split/android/client/SplitClientConfig.java @@ -486,6 +486,10 @@ public long impressionsDedupeTimeInterval() { return mImpressionsDedupeTimeInterval; } + public boolean clearOnInit() { + return false; // TODO: to be implemented in the future + } + public static final class Builder { static final int PROXY_PORT_DEFAULT = 80; diff --git a/src/main/java/io/split/android/client/localhost/LocalhostMySegmentsStorageContainer.java b/src/main/java/io/split/android/client/localhost/LocalhostMySegmentsStorageContainer.java index 32f37c6f0..64304916b 100644 --- a/src/main/java/io/split/android/client/localhost/LocalhostMySegmentsStorageContainer.java +++ b/src/main/java/io/split/android/client/localhost/LocalhostMySegmentsStorageContainer.java @@ -17,4 +17,9 @@ public MySegmentsStorage getStorageForKey(String matchingKey) { public long getUniqueAmount() { return mEmptyMySegmentsStorage.getAll().size(); } + + @Override + public void clear() { + // No-op + } } diff --git a/src/main/java/io/split/android/client/service/synchronizer/RolloutCacheManager.java b/src/main/java/io/split/android/client/service/synchronizer/RolloutCacheManager.java new file mode 100644 index 000000000..372b376d2 --- /dev/null +++ b/src/main/java/io/split/android/client/service/synchronizer/RolloutCacheManager.java @@ -0,0 +1,8 @@ +package io.split.android.client.service.synchronizer; + +import io.split.android.client.service.executor.SplitTaskExecutionListener; + +interface RolloutCacheManager { + + void validateCache(SplitTaskExecutionListener listener); +} diff --git a/src/main/java/io/split/android/client/service/synchronizer/RolloutCacheManagerConfig.java b/src/main/java/io/split/android/client/service/synchronizer/RolloutCacheManagerConfig.java new file mode 100644 index 000000000..1de13b297 --- /dev/null +++ b/src/main/java/io/split/android/client/service/synchronizer/RolloutCacheManagerConfig.java @@ -0,0 +1,29 @@ +package io.split.android.client.service.synchronizer; + +import androidx.annotation.VisibleForTesting; + +import io.split.android.client.SplitClientConfig; + +class RolloutCacheManagerConfig { + + private final long mCacheExpirationInDays; + private final boolean mClearOnInit; + + @VisibleForTesting + RolloutCacheManagerConfig(long cacheExpirationInDays, boolean clearOnInit) { + mCacheExpirationInDays = cacheExpirationInDays; + mClearOnInit = clearOnInit; + } + + static RolloutCacheManagerConfig from(SplitClientConfig splitClientConfig) { + return new RolloutCacheManagerConfig(splitClientConfig.cacheExpirationInSeconds(), splitClientConfig.clearOnInit()); + } + + long getCacheExpirationInDays() { + return mCacheExpirationInDays; + } + + boolean isClearOnInit() { + return mClearOnInit; + } +} diff --git a/src/main/java/io/split/android/client/service/synchronizer/RolloutCacheManagerImpl.java b/src/main/java/io/split/android/client/service/synchronizer/RolloutCacheManagerImpl.java new file mode 100644 index 000000000..254ace2cc --- /dev/null +++ b/src/main/java/io/split/android/client/service/synchronizer/RolloutCacheManagerImpl.java @@ -0,0 +1,101 @@ +package io.split.android.client.service.synchronizer; + +import static io.split.android.client.utils.Utils.checkNotNull; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; + +import java.util.concurrent.TimeUnit; + +import io.split.android.client.SplitClientConfig; +import io.split.android.client.service.executor.SplitTask; +import io.split.android.client.service.executor.SplitTaskExecutionInfo; +import io.split.android.client.service.executor.SplitTaskExecutionListener; +import io.split.android.client.service.executor.SplitTaskExecutor; +import io.split.android.client.service.executor.SplitTaskType; +import io.split.android.client.storage.RolloutDefinitionsCache; +import io.split.android.client.storage.common.SplitStorageContainer; +import io.split.android.client.storage.general.GeneralInfoStorage; +import io.split.android.client.utils.logger.Logger; + +public class RolloutCacheManagerImpl implements RolloutCacheManager, SplitTask { + + public static final int MIN_CACHE_CLEAR_DAYS = 1; // TODO + + @NonNull + private final GeneralInfoStorage mGeneralInfoStorage; + @NonNull + private final RolloutCacheManagerConfig mConfig; + @NonNull + private final SplitTaskExecutor mTaskExecutor; + @NonNull + private final RolloutDefinitionsCache[] mStorages; + + public RolloutCacheManagerImpl(@NonNull SplitClientConfig splitClientConfig, @NonNull SplitTaskExecutor splitTaskExecutor, @NonNull SplitStorageContainer storageContainer) { + this(storageContainer.getGeneralInfoStorage(), + RolloutCacheManagerConfig.from(splitClientConfig), + splitTaskExecutor, + storageContainer.getSplitsStorage(), + storageContainer.getMySegmentsStorageContainer(), + storageContainer.getMyLargeSegmentsStorageContainer()); + } + + @VisibleForTesting + RolloutCacheManagerImpl(@NonNull GeneralInfoStorage generalInfoStorage, @NonNull RolloutCacheManagerConfig config, @NonNull SplitTaskExecutor splitTaskExecutor, @NonNull RolloutDefinitionsCache... storages) { + mGeneralInfoStorage = checkNotNull(generalInfoStorage); + mStorages = checkNotNull(storages); + mConfig = checkNotNull(config); + mTaskExecutor = checkNotNull(splitTaskExecutor); + } + + @Override + public void validateCache(SplitTaskExecutionListener listener) { + mTaskExecutor.submit(this, listener); + } + + @NonNull + @Override + public SplitTaskExecutionInfo execute() { + try { + boolean expired = validateExpiration(); + if (expired) { + clear(); + } + } catch (Exception e) { + Logger.e("Error occurred validating cache: " + e.getMessage()); + + return SplitTaskExecutionInfo.error(SplitTaskType.GENERIC_TASK); + } + return SplitTaskExecutionInfo.success(SplitTaskType.GENERIC_TASK); + } + + private boolean validateExpiration() { + // calculate elapsed time since last update + long lastUpdateTimestamp = mGeneralInfoStorage.getSplitsUpdateTimestamp(); + long daysSinceLastUpdate = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis() - lastUpdateTimestamp); + + if (daysSinceLastUpdate > mConfig.getCacheExpirationInDays()) { + Logger.v("Clearing rollout definitions cache due to expiration"); + return true; + } else if (mConfig.isClearOnInit()) { + long lastCacheClearTimestamp = mGeneralInfoStorage.getRolloutCacheLastClearTimestamp(); + long daysSinceCacheClear = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis() - lastCacheClearTimestamp); + + // don't clear too soon + if (daysSinceCacheClear > MIN_CACHE_CLEAR_DAYS) { + Logger.v("Forcing rollout definitions cache clear"); + return true; + } + } + + return false; + } + + private void clear() { + for (RolloutDefinitionsCache storage : mStorages) { + storage.clear(); + } + mGeneralInfoStorage.setRolloutCacheLastClearTimestamp(System.currentTimeMillis()); + Logger.v("Rollout definitions cache cleared"); + } +} diff --git a/src/main/java/io/split/android/client/storage/RolloutDefinitionsCache.java b/src/main/java/io/split/android/client/storage/RolloutDefinitionsCache.java new file mode 100644 index 000000000..912fafa97 --- /dev/null +++ b/src/main/java/io/split/android/client/storage/RolloutDefinitionsCache.java @@ -0,0 +1,6 @@ +package io.split.android.client.storage; + +public interface RolloutDefinitionsCache { + + void clear(); +} diff --git a/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorage.java b/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorage.java index 351a82d79..2675fc5f1 100644 --- a/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorage.java +++ b/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorage.java @@ -1,12 +1,11 @@ package io.split.android.client.storage.mysegments; -import androidx.annotation.VisibleForTesting; - import java.util.Set; import io.split.android.client.dtos.SegmentsChange; +import io.split.android.client.storage.RolloutDefinitionsCache; -public interface MySegmentsStorage { +public interface MySegmentsStorage extends RolloutDefinitionsCache { void loadLocal(); Set getAll(); @@ -14,7 +13,4 @@ public interface MySegmentsStorage { void set(SegmentsChange segmentsChange); long getChangeNumber(); - - @VisibleForTesting - void clear(); } diff --git a/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageContainer.java b/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageContainer.java index 98e656071..9feeded60 100644 --- a/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageContainer.java +++ b/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageContainer.java @@ -1,6 +1,8 @@ package io.split.android.client.storage.mysegments; -public interface MySegmentsStorageContainer { +import io.split.android.client.storage.RolloutDefinitionsCache; + +public interface MySegmentsStorageContainer extends RolloutDefinitionsCache { MySegmentsStorage getStorageForKey(String matchingKey); diff --git a/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageContainerImpl.java b/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageContainerImpl.java index 8a1ee9187..0a8f51f45 100644 --- a/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageContainerImpl.java +++ b/src/main/java/io/split/android/client/storage/mysegments/MySegmentsStorageContainerImpl.java @@ -41,4 +41,13 @@ public long getUniqueAmount() { return segments.size(); } + + @Override + public void clear() { + synchronized (lock) { + for (MySegmentsStorage mySegmentsStorage : mStorageMap.values()) { + mySegmentsStorage.clear(); + } + } + } } diff --git a/src/main/java/io/split/android/client/storage/splits/SplitsStorage.java b/src/main/java/io/split/android/client/storage/splits/SplitsStorage.java index 70d228726..81ff4e5a5 100644 --- a/src/main/java/io/split/android/client/storage/splits/SplitsStorage.java +++ b/src/main/java/io/split/android/client/storage/splits/SplitsStorage.java @@ -9,8 +9,9 @@ import java.util.Set; import io.split.android.client.dtos.Split; +import io.split.android.client.storage.RolloutDefinitionsCache; -public interface SplitsStorage { +public interface SplitsStorage extends RolloutDefinitionsCache { void loadLocal(); Split get(@NonNull String name); @@ -38,8 +39,6 @@ public interface SplitsStorage { void updateFlagsSpec(String flagsSpec); - void clear(); - @NonNull Set getNamesByFlagSets(Collection flagSets); } diff --git a/src/test/java/io/split/android/client/service/synchronizer/RolloutCacheManagerTest.kt b/src/test/java/io/split/android/client/service/synchronizer/RolloutCacheManagerTest.kt new file mode 100644 index 000000000..41dcc6422 --- /dev/null +++ b/src/test/java/io/split/android/client/service/synchronizer/RolloutCacheManagerTest.kt @@ -0,0 +1,145 @@ +package io.split.android.client.service.synchronizer + +import io.split.android.client.service.executor.SplitTaskExecutionListener +import io.split.android.client.service.executor.SplitTaskExecutor +import io.split.android.client.storage.RolloutDefinitionsCache +import io.split.android.client.storage.general.GeneralInfoStorage +import io.split.android.fake.SplitTaskExecutorStub +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.ArgumentMatchers.argThat +import org.mockito.Mockito.longThat +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import java.util.concurrent.TimeUnit + +class RolloutCacheManagerTest { + + private lateinit var mRolloutCacheManager: RolloutCacheManager + private lateinit var mGeneralInfoStorage: GeneralInfoStorage + private lateinit var mSplitTaskExecutor: SplitTaskExecutor + private lateinit var mSplitsCache: RolloutDefinitionsCache + private lateinit var mSegmentsCache: RolloutDefinitionsCache + + @Before + fun setup() { + mGeneralInfoStorage = mock(GeneralInfoStorage::class.java) + mSplitTaskExecutor = SplitTaskExecutorStub() + mSplitsCache = mock(RolloutDefinitionsCache::class.java) + mSegmentsCache = mock(RolloutDefinitionsCache::class.java) + } + + @Test + fun `validateCache calls listener`() { + mRolloutCacheManager = getCacheManager(10L, false) + + val listener = mock(SplitTaskExecutionListener::class.java) + mRolloutCacheManager.validateCache(listener) + + verify(listener).taskExecuted(any()) + } + + @Test + fun `validateCache calls clear on storages when expiration is surpassed`() { + val mockedTimestamp = createMockedTimestamp(10L) + `when`(mGeneralInfoStorage.splitsUpdateTimestamp).thenReturn(mockedTimestamp) + mRolloutCacheManager = getCacheManager(9L, false) + + mRolloutCacheManager.validateCache(mock(SplitTaskExecutionListener::class.java)) + + verify(mSplitsCache).clear() + verify(mSegmentsCache).clear() + } + + @Test + fun `validateCache does not call clear on storages when expiration is not surpassed and clearOnInit is false`() { + val mockedTimestamp = createMockedTimestamp(1L) + `when`(mGeneralInfoStorage.splitsUpdateTimestamp).thenReturn(mockedTimestamp) + mRolloutCacheManager = getCacheManager(10L, false) + + mRolloutCacheManager.validateCache(mock(SplitTaskExecutionListener::class.java)) + + verify(mSplitsCache, times(0)).clear() + verify(mSegmentsCache, times(0)).clear() + } + + @Test + fun `validateCache calls clear on storages when expiration is not surpassed and clearOnInit is true`() { + val mockedTimestamp = createMockedTimestamp(1L) + `when`(mGeneralInfoStorage.splitsUpdateTimestamp).thenReturn(mockedTimestamp) + mRolloutCacheManager = getCacheManager(10L, true) + + mRolloutCacheManager.validateCache(mock(SplitTaskExecutionListener::class.java)) + + verify(mSplitsCache).clear() + verify(mSegmentsCache).clear() + } + + @Test + fun `validateCache calls clear on storage only once when executed consecutively`() { + val mockedTimestamp = createMockedTimestamp(1L) + `when`(mGeneralInfoStorage.splitsUpdateTimestamp).thenReturn(mockedTimestamp) + `when`(mGeneralInfoStorage.rolloutCacheLastClearTimestamp).thenReturn(0L).thenReturn(TimeUnit.HOURS.toMillis(TimeUnit.MILLISECONDS.toHours(System.currentTimeMillis()) - 1)) + mRolloutCacheManager = getCacheManager(10L, true) + + mRolloutCacheManager.validateCache(mock(SplitTaskExecutionListener::class.java)) + mRolloutCacheManager.validateCache(mock(SplitTaskExecutionListener::class.java)) + + verify(mSplitsCache, times(1)).clear() + verify(mSegmentsCache, times(1)).clear() + } + + @Test + fun `exception during clear still calls listener`() { + val mockedTimestamp = createMockedTimestamp(1L) + `when`(mGeneralInfoStorage.splitsUpdateTimestamp).thenReturn(mockedTimestamp) + `when`(mGeneralInfoStorage.rolloutCacheLastClearTimestamp).thenReturn(0L).thenReturn(TimeUnit.HOURS.toMillis(TimeUnit.MILLISECONDS.toHours(System.currentTimeMillis()) - 1)) + mRolloutCacheManager = getCacheManager(10L, true) + + val listener = mock(SplitTaskExecutionListener::class.java) + `when`(mSplitsCache.clear()).thenThrow(RuntimeException("Exception during clear")) + + mRolloutCacheManager.validateCache(listener) + + verify(listener).taskExecuted(any()) + } + + @Test + fun `validateCache updates last clear timestamp when storages are cleared`() { + val mockedTimestamp = createMockedTimestamp(1L) + `when`(mGeneralInfoStorage.splitsUpdateTimestamp).thenReturn(mockedTimestamp) + `when`(mGeneralInfoStorage.rolloutCacheLastClearTimestamp).thenReturn(0L).thenReturn(TimeUnit.HOURS.toMillis(TimeUnit.MILLISECONDS.toHours(System.currentTimeMillis()) - 1)) + mRolloutCacheManager = getCacheManager(10L, true) + + mRolloutCacheManager.validateCache(mock(SplitTaskExecutionListener::class.java)) + + verify(mGeneralInfoStorage).setRolloutCacheLastClearTimestamp(longThat { it > 0 }) + } + + @Test + fun `validateCache does not update last clear timestamp when storages are not cleared`() { + val mockedTimestamp = createMockedTimestamp(1L) + `when`(mGeneralInfoStorage.splitsUpdateTimestamp).thenReturn(mockedTimestamp) + `when`(mGeneralInfoStorage.rolloutCacheLastClearTimestamp).thenReturn(0L).thenReturn(TimeUnit.HOURS.toMillis(TimeUnit.MILLISECONDS.toHours(System.currentTimeMillis()) - 1)) + mRolloutCacheManager = getCacheManager(10L, false) + + mRolloutCacheManager.validateCache(mock(SplitTaskExecutionListener::class.java)) + + verify(mGeneralInfoStorage, times(0)).setRolloutCacheLastClearTimestamp(anyLong()) + } + + private fun getCacheManager(expiration: Long, clearOnInit: Boolean): RolloutCacheManager { + return RolloutCacheManagerImpl(mGeneralInfoStorage, RolloutCacheManagerConfig(expiration, clearOnInit), mSplitTaskExecutor, mSplitsCache, mSegmentsCache) + } + + private fun createMockedTimestamp(period: Long): Long { + val currentTimeMillis = System.currentTimeMillis() + val mockedTimestamp = + TimeUnit.DAYS.toMillis(TimeUnit.MILLISECONDS.toDays(currentTimeMillis) - period) + return mockedTimestamp + } +}