diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java index 134b04dc..ac0cb66f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java @@ -32,21 +32,25 @@ final class ClientContextImpl extends ClientContext { private final FeatureFetcher fetcher; private final PlatformState platformState; private final TaskExecutor taskExecutor; + private final PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData; ClientContextImpl( ClientContext base, DiagnosticStore diagnosticStore, FeatureFetcher fetcher, PlatformState platformState, - TaskExecutor taskExecutor + TaskExecutor taskExecutor, + PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData ) { super(base); this.diagnosticStore = diagnosticStore; this.fetcher = fetcher; this.platformState = platformState; this.taskExecutor = taskExecutor; + this.perEnvironmentData = perEnvironmentData; } + // TODO: consider re-ordering so perEnvironmentData is earlier in list of params static ClientContextImpl fromConfig( LDConfig config, String mobileKey, @@ -56,7 +60,8 @@ static ClientContextImpl fromConfig( LDLogger logger, PlatformState platformState, IEnvironmentReporter environmentReporter, - TaskExecutor taskExecutor + TaskExecutor taskExecutor, + PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData ) { boolean initiallyInBackground = platformState != null && !platformState.isForeground(); ClientContext minimalContext = new ClientContext(mobileKey, environmentReporter, logger, config, @@ -82,14 +87,14 @@ static ClientContextImpl fromConfig( if (!config.getDiagnosticOptOut()) { diagnosticStore = new DiagnosticStore(EventUtil.makeDiagnosticParams(baseClientContext)); } - return new ClientContextImpl(baseClientContext, diagnosticStore, fetcher, platformState, taskExecutor); + return new ClientContextImpl(baseClientContext, diagnosticStore, fetcher, platformState, taskExecutor, perEnvironmentData); } public static ClientContextImpl get(ClientContext context) { if (context instanceof ClientContextImpl) { return (ClientContextImpl)context; } - return new ClientContextImpl(context, null, null, null, null); + return new ClientContextImpl(context, null, null, null, null, null); } public static ClientContextImpl forDataSource( @@ -119,7 +124,8 @@ public static ClientContextImpl forDataSource( baseContextImpl.getDiagnosticStore(), baseContextImpl.getFetcher(), baseContextImpl.getPlatformState(), - baseContextImpl.getTaskExecutor() + baseContextImpl.getTaskExecutor(), + baseContextImpl.getPerEnvironmentData() ); } @@ -134,7 +140,8 @@ public ClientContextImpl setEvaluationContext(LDContext context) { this.diagnosticStore, this.fetcher, this.platformState, - this.taskExecutor + this.taskExecutor, + this.perEnvironmentData ); } @@ -154,6 +161,10 @@ public TaskExecutor getTaskExecutor() { return throwExceptionIfNull(taskExecutor); } + public PersistentDataStoreWrapper.PerEnvironmentData getPerEnvironmentData() { + return throwExceptionIfNull(perEnvironmentData); + } + private static T throwExceptionIfNull(T o) { if (o == null) { throw new IllegalStateException( diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java index 605d98c3..489b2214 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java @@ -244,30 +244,45 @@ static final class PollingDataSourceBuilderImpl extends PollingDataSourceBuilder implements DiagnosticDescription, DataSourceRequiresFeatureFetcher { @Override public DataSource build(ClientContext clientContext) { - clientContext.getDataSourceUpdateSink().setStatus( - clientContext.isInBackground() ? ConnectionInformation.ConnectionMode.BACKGROUND_POLLING : + ClientContextImpl clientContextImpl = ClientContextImpl.get(clientContext); + clientContextImpl.getDataSourceUpdateSink().setStatus( + clientContextImpl.isInBackground() ? ConnectionInformation.ConnectionMode.BACKGROUND_POLLING : ConnectionInformation.ConnectionMode.POLLING, null ); - int actualPollIntervalMillis = clientContext.isInBackground() ? backgroundPollIntervalMillis : + + int pollInterval = clientContextImpl.isInBackground() ? backgroundPollIntervalMillis : pollIntervalMillis; - int initialDelayMillis; + + long initialDelayMillis; if (clientContext.isInBackground() && Boolean.FALSE.equals(clientContext.getPreviouslyInBackground())) { // If we're transitioning from foreground to background, then we don't want to do a // poll right away because we already have recent flag data. Start polling *after* // the first background poll interval. initialDelayMillis = backgroundPollIntervalMillis; } else { - // If we're in the foreground-- or, if we're in the background but we started out - // that way rather than transitioning-- then we should do the first poll right away. - initialDelayMillis = 0; + // TODO: refactor context hashing calls to use a common util + String hashed = new ContextHasher().hash(clientContextImpl.getEvaluationContext().getFullyQualifiedKey()); + + long lastUpdated = 0; // assume last updated is beginning of time + for (ContextIndex.IndexEntry entry : clientContextImpl.getPerEnvironmentData().getIndex().data) { + if (entry.contextId.equals(hashed)) { + lastUpdated = entry.timestamp; + } + } + + // To avoid unnecessarily frequent polling requests due to process or application lifecycle, we have added + // this initial delay logic. Calculate how much time has passed since the last update, if that is less than + // the polling interval, delay by the difference, otherwise 0 delay. + long elapsedSinceUpdate = System.currentTimeMillis() - lastUpdated; + initialDelayMillis = Math.max(pollInterval - elapsedSinceUpdate, 0); } - ClientContextImpl clientContextImpl = ClientContextImpl.get(clientContext); + return new PollingDataSource( - clientContext.getEvaluationContext(), - clientContext.getDataSourceUpdateSink(), + clientContextImpl.getEvaluationContext(), + clientContextImpl.getDataSourceUpdateSink(), initialDelayMillis, - actualPollIntervalMillis, + pollInterval, clientContextImpl.getFetcher(), clientContextImpl.getPlatformState(), clientContextImpl.getTaskExecutor(), diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ContextDataManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ContextDataManager.java index e997f033..c7695319 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ContextDataManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ContextDataManager.java @@ -243,6 +243,12 @@ public boolean upsert(@NonNull LDContext context, @NonNull Flag flag) { String contextId = hashedContextId(context); environmentStore.setContextData(contextId, updatedFlags); + + if (index == null) { + index = environmentStore.getIndex(); + } + index = index.updateTimestamp(contextId, System.currentTimeMillis()); + environmentStore.setIndex(index); } Collection updatedFlag = Collections.singletonList(flag.getKey()); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java index 0129032e..c9a38e17 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java @@ -330,7 +330,7 @@ protected LDClient( FeatureFetcher fetcher = null; if (config.dataSource instanceof ComponentsImpl.DataSourceRequiresFeatureFetcher) { ClientContextImpl minimalContext = ClientContextImpl.fromConfig(config, mobileKey, - environmentName, null, initialContext, logger, platformState, environmentReporter, taskExecutor + environmentName, null, initialContext, logger, platformState, environmentReporter, taskExecutor, environmentStore ); fetcher = new HttpFeatureFlagFetcher(minimalContext); } @@ -344,7 +344,8 @@ protected LDClient( logger, platformState, environmentReporter, - taskExecutor + taskExecutor, + environmentStore ); this.contextDataManager = new ContextDataManager( diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollingDataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollingDataSource.java index 371d31f0..c50a527d 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollingDataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PollingDataSource.java @@ -20,8 +20,8 @@ final class PollingDataSource implements DataSource { private final LDContext currentContext; private final DataSourceUpdateSink dataSourceUpdateSink; - final int initialDelayMillis; // visible for testing - final int pollIntervalMillis; // visible for testing + final long initialDelayMillis; // visible for testing + final long pollIntervalMillis; // visible for testing private final FeatureFetcher fetcher; private final PlatformState platformState; private final TaskExecutor taskExecutor; @@ -32,8 +32,8 @@ final class PollingDataSource implements DataSource { PollingDataSource( LDContext currentContext, DataSourceUpdateSink dataSourceUpdateSink, - int initialDelayMillis, - int pollIntervalMillis, + long initialDelayMillis, + long pollIntervalMillis, FeatureFetcher fetcher, PlatformState platformState, TaskExecutor taskExecutor, diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java index 290a90d1..cbfadae5 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java @@ -116,7 +116,8 @@ private void createTestManager( logging.logger, mockPlatformState, environmentReporter, - taskExecutor + taskExecutor, + environmentStore ); contextDataManager = new ContextDataManager( diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerTestBase.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerTestBase.java index d403a2ac..f08895fd 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerTestBase.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ContextDataManagerTestBase.java @@ -55,7 +55,8 @@ protected ContextDataManager createDataManager(int maxCachedContexts) { logging.logger, null, environmentReporter, - taskExecutor + taskExecutor, + environmentStore ); return new ContextDataManager( clientContext, diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DiagnosticConfigTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DiagnosticConfigTest.java index ed9507e4..0c7f49d8 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DiagnosticConfigTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/DiagnosticConfigTest.java @@ -142,7 +142,7 @@ public void customDiagnosticConfigurationHttp() throws Exception { private static LDValue makeDiagnosticJson(LDConfig config) throws Exception { ClientContext clientContext = ClientContextImpl.fromConfig(config, "", "", - null, null, LDLogger.none(), null, new EnvironmentReporterBuilder().build(), null); + null, null, LDLogger.none(), null, new EnvironmentReporterBuilder().build(), null, null); DiagnosticStore.SdkDiagnosticParams params = EventUtil.makeDiagnosticParams(clientContext); DiagnosticStore diagnosticStore = new DiagnosticStore(params); MockDiagnosticEventSender mockSender = new MockDiagnosticEventSender(); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java index f5328cd3..a890bb37 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/LDConfigTest.java @@ -75,7 +75,7 @@ Map headersToMap(Headers headers) { public void headersForEnvironment() { LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled).mobileKey("test-key").build(); ClientContext clientContext = ClientContextImpl.fromConfig(config, "test-key", "", - null, null, null, null, new EnvironmentReporterBuilder().build(), null); + null, null, null, null, new EnvironmentReporterBuilder().build(), null, null); Map headers = headersToMap( LDUtil.makeHttpProperties(clientContext).toHeadersBuilder().build() ); @@ -96,7 +96,7 @@ public void headersForEnvironmentWithTransform() { })) .build(); ClientContext clientContext = ClientContextImpl.fromConfig(config, "test-key", "", - null, null, null, null, new EnvironmentReporterBuilder().build(), null); + null, null, null, null, new EnvironmentReporterBuilder().build(), null, null); expected.put("User-Agent", LDUtil.USER_AGENT_HEADER_VALUE); expected.put("Authorization", "api_key test-key"); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PollingDataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PollingDataSourceTest.java index c022a8c3..aa720507 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PollingDataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/PollingDataSourceTest.java @@ -3,6 +3,7 @@ import static com.launchdarkly.sdk.android.AssertHelpers.requireNoMoreValues; import static com.launchdarkly.sdk.android.AssertHelpers.requireValue; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.LDValue; @@ -15,6 +16,7 @@ import com.launchdarkly.sdk.android.subsystems.DataSource; import org.easymock.EasyMockSupport; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -24,8 +26,9 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; -public class PollingDataSourceTest extends EasyMockSupport { +public class PollingDataSourceTest { private static final LDContext CONTEXT = LDContext.create("context-key"); + private static final String MOBILE_KEY = "test-mobile-key"; private static final LDConfig EMPTY_CONFIG = new LDConfig.Builder(AutoEnvAttributes.Disabled).build(); private final MockComponents.MockDataSourceUpdateSink dataSourceUpdateSink = new MockComponents.MockDataSourceUpdateSink(); @@ -34,14 +37,20 @@ public class PollingDataSourceTest extends EasyMockSupport { private final IEnvironmentReporter environmentReporter = new EnvironmentReporterBuilder().build(); private final SimpleTestTaskExecutor taskExecutor = new SimpleTestTaskExecutor(); + private PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData; @Rule public LogCaptureRule logging = new LogCaptureRule(); - private ClientContext makeClientContext(boolean inBackground, Boolean previouslyInBackground) { - ClientContext baseClientContext = ClientContextImpl.fromConfig( + @Before + public void before() { + perEnvironmentData = TestUtil.makeSimplePersistentDataStoreWrapper().perEnvironmentData(MOBILE_KEY); + } + + private ClientContextImpl makeClientContext(boolean inBackground, Boolean previouslyInBackground) { + ClientContextImpl baseClientContext = ClientContextImpl.fromConfig( EMPTY_CONFIG, "", "", fetcher, CONTEXT, - logging.logger, platformState, environmentReporter, taskExecutor); + logging.logger, platformState, environmentReporter, taskExecutor, perEnvironmentData); return ClientContextImpl.forDataSource( baseClientContext, dataSourceUpdateSink, @@ -115,6 +124,27 @@ public void firstPollIsImmediateWhenStartingInBackground() throws Exception { } } + @Test + public void pollingIntervalHonoredAcrossMultipleBuildCalls() throws Exception { + ClientContextImpl clientContext = makeClientContext(true, null); + PollingDataSourceBuilder builder = Components.pollingDataSource() + .pollIntervalMillis(100000) + .backgroundPollIntervalMillis(100000); + + // first build should have no delay + PollingDataSource ds1 = (PollingDataSource) builder.build(clientContext); + assertEquals(0, ds1.initialDelayMillis); + + // simulate successful update of context index timestamp + String hash = new ContextHasher().hash(CONTEXT.getFullyQualifiedKey()); + ContextIndex newIndex = clientContext.getPerEnvironmentData().getIndex().updateTimestamp(hash, System.currentTimeMillis()); + clientContext.getPerEnvironmentData().setIndex(newIndex); + + // second build should have a non-zero delay due to simulated response storing a recent timestamp + PollingDataSource ds2 = (PollingDataSource) builder.build(clientContext); + assertNotEquals(0, ds2.initialDelayMillis); + } + @Test public void firstPollHappensAfterBackgroundPollingIntervalWhenTransitioningToBackground() throws Exception { ClientContext clientContext = makeClientContext(true, false); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java index 8346dee5..e9ac2795 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StreamingDataSourceTest.java @@ -11,6 +11,7 @@ import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.DataSource; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -23,20 +24,24 @@ public class StreamingDataSourceTest { // the tests here cover other aspects of how the streaming data source component behaves. private static final LDContext CONTEXT = LDContext.create("context-key"); - + private static final String MOBILE_KEY = "test-mobile-key"; @Rule public LogCaptureRule logging = new LogCaptureRule(); - private final MockComponents.MockDataSourceUpdateSink dataSourceUpdateSink = new MockComponents.MockDataSourceUpdateSink(); private final MockPlatformState platformState = new MockPlatformState(); - private final IEnvironmentReporter environmentReporter = new EnvironmentReporterBuilder().build(); private final SimpleTestTaskExecutor taskExecutor = new SimpleTestTaskExecutor(); + private PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData; + + @Before + public void before() { + perEnvironmentData = TestUtil.makeSimplePersistentDataStoreWrapper().perEnvironmentData(MOBILE_KEY); + } private ClientContext makeClientContext(boolean inBackground, Boolean previouslyInBackground) { ClientContext baseClientContext = ClientContextImpl.fromConfig( new LDConfig.Builder(AutoEnvAttributes.Disabled).build(), "", "", null, CONTEXT, - logging.logger, platformState, environmentReporter, taskExecutor); + logging.logger, platformState, environmentReporter, taskExecutor, perEnvironmentData); return ClientContextImpl.forDataSource( baseClientContext, dataSourceUpdateSink, @@ -53,7 +58,7 @@ private ClientContext makeClientContext(boolean inBackground, Boolean previously private ClientContext makeClientContextWithFetcher() { ClientContext baseClientContext = ClientContextImpl.fromConfig( new LDConfig.Builder(AutoEnvAttributes.Disabled).build(), "", "", makeFeatureFetcher(), CONTEXT, - logging.logger, platformState, environmentReporter, taskExecutor); + logging.logger, platformState, environmentReporter, taskExecutor, perEnvironmentData); return ClientContextImpl.forDataSource( baseClientContext, dataSourceUpdateSink,