Skip to content

Commit

Permalink
Rollout cache manager (#717)
Browse files Browse the repository at this point in the history
  • Loading branch information
gthea authored Nov 28, 2024
1 parent a5a56c9 commit 35ed81b
Show file tree
Hide file tree
Showing 11 changed files with 314 additions and 10 deletions.
4 changes: 4 additions & 0 deletions src/main/java/io/split/android/client/SplitClientConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,9 @@ public MySegmentsStorage getStorageForKey(String matchingKey) {
public long getUniqueAmount() {
return mEmptyMySegmentsStorage.getAll().size();
}

@Override
public void clear() {
// No-op
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.split.android.client.storage;

public interface RolloutDefinitionsCache {

void clear();
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
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<String> getAll();

void set(SegmentsChange segmentsChange);

long getChangeNumber();

@VisibleForTesting
void clear();
}
Original file line number Diff line number Diff line change
@@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,13 @@ public long getUniqueAmount() {

return segments.size();
}

@Override
public void clear() {
synchronized (lock) {
for (MySegmentsStorage mySegmentsStorage : mStorageMap.values()) {
mySegmentsStorage.clear();
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -38,8 +39,6 @@ public interface SplitsStorage {

void updateFlagsSpec(String flagsSpec);

void clear();

@NonNull
Set<String> getNamesByFlagSets(Collection<String> flagSets);
}
Original file line number Diff line number Diff line change
@@ -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
}
}

0 comments on commit 35ed81b

Please sign in to comment.