Skip to content

Commit

Permalink
feat: delete expired background queue tasks
Browse files Browse the repository at this point in the history
  • Loading branch information
levibostian committed Jun 8, 2022
1 parent 79ebdec commit 8dca8b7
Show file tree
Hide file tree
Showing 22 changed files with 363 additions and 117 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ fun Date.add(unit: Long, type: TimeUnit): Date {
return Date(this.time + type.toMillis(unit))
}

fun Date.subtract(unit: Double, type: TimeUnit): Date = this.subtract(unit.toLong(), type)
fun Date.subtract(unit: Int, type: TimeUnit): Date = this.subtract(unit.toLong(), type)
fun Date.subtract(unit: Long, type: TimeUnit): Date {
return Date(this.time - type.toMillis(unit))
Expand All @@ -26,3 +27,7 @@ fun Date.subtract(unit: Long, type: TimeUnit): Date {
fun Date.hasPassed(): Boolean {
return this.time < Date().time
}

fun Date.isOlderThan(otherDate: Date): Boolean {
return this.time < otherDate.time
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ package io.customer.base.extensions
import io.customer.base.extenstions.add
import io.customer.base.extenstions.getUnixTimestamp
import io.customer.base.extenstions.hasPassed
import io.customer.base.extenstions.isOlderThan
import io.customer.base.extenstions.subtract
import io.customer.base.extenstions.unixTimeToDate
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldBeFalse
import org.amshove.kluent.shouldBeTrue
import org.junit.Test
import java.text.SimpleDateFormat
import java.util.*
Expand Down Expand Up @@ -61,4 +64,14 @@ class DateExtensionsTest {
fun hasPassed_givenDateInFuture_expectFalse() {
Date().add(1, TimeUnit.MINUTES).hasPassed() shouldBeEqualTo false
}

@Test
fun isOlderThan_givenDateThatIsOlder_expectTrue() {
Date().subtract(2, TimeUnit.DAYS).isOlderThan(Date().subtract(1, TimeUnit.DAYS)).shouldBeTrue()
}

@Test
fun isOlderThan_givenDateThatIsNewer_expectFalse() {
Date().subtract(1, TimeUnit.DAYS).isOlderThan(Date().subtract(2, TimeUnit.DAYS)).shouldBeFalse()
}
}
17 changes: 12 additions & 5 deletions common-test/src/main/java/io/customer/common_test/BaseTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import android.app.Application
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
import io.customer.common_test.util.DispatchersProviderStub
import io.customer.sdk.CustomerIOConfig
import io.customer.sdk.data.model.Region
import io.customer.sdk.data.store.DeviceStore
import io.customer.sdk.di.CustomerIOComponent
import io.customer.sdk.util.CioLogLevel
import io.customer.sdk.util.DateUtil
import io.customer.sdk.util.DispatchersProvider
import io.customer.sdk.util.JsonAdapter
import kotlinx.coroutines.test.TestCoroutineDispatcher
import io.customer.sdk.util.Seconds
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
Expand All @@ -36,9 +38,7 @@ abstract class BaseTest {
protected lateinit var cioConfig: CustomerIOConfig

protected val deviceStore: DeviceStore = DeviceStoreStub().deviceStore

// when you need a CoroutineDispatcher in a test function, use this as it runs your tests synchronous.
protected val testDispatcher = TestCoroutineDispatcher()
protected lateinit var dispatchersProviderStub: DispatchersProviderStub

protected lateinit var di: CustomerIOComponent
protected val jsonAdapter: JsonAdapter
Expand All @@ -53,13 +53,16 @@ abstract class BaseTest {

@Before
open fun setup() {
cioConfig = CustomerIOConfig(siteId, "xyz", Region.EU, 100, null, true, true, 10, 30.0, CioLogLevel.DEBUG, null)
cioConfig = CustomerIOConfig(siteId, "xyz", Region.EU, 100, null, true, true, 10, 30.0, Seconds.fromDays(3).value, CioLogLevel.DEBUG, null)

// Initialize the mock web server before constructing DI graph as dependencies may require information such as hostname.
mockWebServer = MockWebServer().apply {
start()
}
cioConfig.trackingApiUrl = mockWebServer.url("/").toString()
if (!cioConfig.trackingApiUrl!!.contains("localhost")) {
throw RuntimeException("server didnt' start ${cioConfig.trackingApiUrl}")
}

di = CustomerIOComponent(
sdkConfig = cioConfig,
Expand All @@ -71,10 +74,14 @@ abstract class BaseTest {
dateUtilStub = DateUtilStub().also {
di.overrideDependency(DateUtil::class.java, it)
}
dispatchersProviderStub = DispatchersProviderStub().also {
di.overrideDependency(DispatchersProvider::class.java, it)
}
}

@After
open fun teardown() {
mockWebServer.shutdown()
di.reset()
}
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
package io.customer.common_test

import io.customer.base.extenstions.unixTimeToDate
import io.customer.base.extenstions.getUnixTimestamp
import io.customer.sdk.util.DateUtil
import java.util.*

/**
* Convenient alternative to mocking [DateUtil] in your test since the code is boilerplate.
*/
class DateUtilStub : DateUtil {
// modify this value in your test class if you need to.
var givenDateMillis = 1646238885L

val givenDate: Date
get() = givenDateMillis.unixTimeToDate()
// modify this value in your test class if you need to.
var givenDate: Date = Date(1646238885L)

override val now: Date
get() = givenDate

override val nowUnixTimestamp: Long
get() = givenDateMillis
get() = now.getUnixTimestamp()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.customer.common_test.util

import io.customer.sdk.util.DispatchersProvider
import io.customer.sdk.util.SdkDispatchers
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineDispatcher

class DispatchersProviderStub : DispatchersProvider {
private var overrideBackground: CoroutineDispatcher? = null
private var overrideMain: CoroutineDispatcher? = null

// If your test function requires real dispatchers to be used, call this function.
// the default behavior is test dispatchers because they are fast and synchronous for more predictable test execution.
fun setRealDispatchers() {
SdkDispatchers().also {
overrideBackground = it.background
overrideMain = it.main
}
}

@OptIn(ExperimentalCoroutinesApi::class)
override val background: CoroutineDispatcher
get() = overrideBackground ?: TestCoroutineDispatcher()

@OptIn(ExperimentalCoroutinesApi::class)
override val main: CoroutineDispatcher
get() = overrideMain ?: TestCoroutineDispatcher()
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="run Android tests" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests">
<configuration default="false" name="run Android tests" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests" singleton="true">
<module name="Customer.io_SDK.sdk" />
<option name="TESTING_TYPE" value="1" />
<option name="METHOD_NAME" value="" />
Expand All @@ -11,9 +11,9 @@
<option name="RETENTION_ENABLED" value="No" />
<option name="RETENTION_MAX_SNAPSHOTS" value="2" />
<option name="RETENTION_COMPRESS_SNAPSHOTS" value="false" />
<option name="CLEAR_LOGCAT" value="false" />
<option name="CLEAR_LOGCAT" value="true" />
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
<option name="SKIP_NOOP_APK_INSTALLATIONS" value="true" />
<option name="SKIP_NOOP_APK_INSTALLATIONS" value="false" />
<option name="FORCE_STOP_RUNNING_APP" value="true" />
<option name="INSPECTION_WITHOUT_ACTIVITY_RESTART" value="false" />
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
Expand Down
20 changes: 19 additions & 1 deletion sdk/src/main/java/io/customer/sdk/CustomerIO.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ import io.customer.sdk.data.communication.CustomerIOUrlHandler
import io.customer.sdk.data.model.Region
import io.customer.sdk.data.request.MetricEvent
import io.customer.sdk.di.CustomerIOComponent
import io.customer.sdk.repository.CleanupRepository
import io.customer.sdk.extensions.getScreenNameFromActivity
import io.customer.sdk.repository.DeviceRepository
import io.customer.sdk.repository.ProfileRepository
import io.customer.sdk.repository.TrackRepository
import io.customer.sdk.util.CioLogLevel
import io.customer.sdk.util.Seconds
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

/**
* Allows mocking of [CustomerIO] for your automated tests in your project. Mock [CustomerIO] to assert your code is calling functions
Expand Down Expand Up @@ -105,6 +109,7 @@ class CustomerIO internal constructor(
private var autoTrackDeviceAttributes: Boolean = true
private var modules: MutableMap<String, CustomerIOModule> = mutableMapOf()
private var logLevel = CioLogLevel.ERROR
internal var overrideDiGraph: CustomerIOComponent? = null // set for automated tests
private var trackingApiUrl: String? = null

private lateinit var activityLifecycleCallback: CustomerIOActivityLifecycleCallbacks
Expand Down Expand Up @@ -178,11 +183,12 @@ class CustomerIO internal constructor(
autoTrackDeviceAttributes = autoTrackDeviceAttributes,
backgroundQueueMinNumberOfTasks = 10,
backgroundQueueSecondsDelay = 30.0,
backgroundQueueTaskExpiredSeconds = Seconds.fromDays(3).value,
logLevel = logLevel,
trackingApiUrl = trackingApiUrl
)

val diGraph = CustomerIOComponent(sdkConfig = config, context = appContext)
val diGraph = overrideDiGraph ?: CustomerIOComponent(sdkConfig = config, context = appContext)
val client = CustomerIO(diGraph)
val logger = diGraph.logger

Expand All @@ -196,6 +202,8 @@ class CustomerIO internal constructor(
it.value.initialize()
}

client.postInitialize()

return client
}
}
Expand All @@ -215,6 +223,16 @@ class CustomerIO internal constructor(
override val sdkVersion: String
get() = Version.version

private val cleanupRepository: CleanupRepository
get() = diGraph.cleanupRepository

private fun postInitialize() {
// run cleanup asynchronously in background to prevent taking up the main/UI thread
CoroutineScope(diGraph.dispatchersProvider.background).launch {
cleanupRepository.cleanup()
}
}

/**
* Identify a customer (aka: Add or update a profile).
* [Learn more](https://customer.io/docs/identifying-people/) about identifying a customer in Customer.io
Expand Down
5 changes: 5 additions & 0 deletions sdk/src/main/java/io/customer/sdk/CustomerIOConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ data class CustomerIOConfig(
*/

val backgroundQueueSecondsDelay: Double,
/**
* The number of seconds old a queue task is when it is "expired" and should be deleted.
* We do not recommend modifying this value because it risks losing data or taking up too much space on the user's device.
*/
val backgroundQueueTaskExpiredSeconds: Double,
val logLevel: CioLogLevel,
var trackingApiUrl: String?,
) {
Expand Down
17 changes: 12 additions & 5 deletions sdk/src/main/java/io/customer/sdk/di/CustomerIOComponent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@ import io.customer.sdk.repository.*
import io.customer.sdk.util.AndroidSimpleTimer
import io.customer.sdk.util.DateUtil
import io.customer.sdk.util.DateUtilImpl
import io.customer.sdk.util.DispatchersProvider
import io.customer.sdk.util.JsonAdapter
import io.customer.sdk.util.LogcatLogger
import io.customer.sdk.util.Logger
import io.customer.sdk.util.SdkDispatchers
import io.customer.sdk.util.SimpleTimer
import kotlinx.coroutines.Dispatchers
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
Expand All @@ -56,16 +57,22 @@ class CustomerIOComponent(
get() = override() ?: JsonAdapter(moshi)

val queueStorage: QueueStorage
get() = override() ?: QueueStorageImpl(sdkConfig, fileStorage, jsonAdapter, logger)
get() = override() ?: QueueStorageImpl(sdkConfig, fileStorage, jsonAdapter, dateUtil, logger)

val queueRunner: QueueRunner
get() = override() ?: QueueRunnerImpl(jsonAdapter, cioHttpClient, logger)

val dispatchersProvider: DispatchersProvider
get() = override() ?: SdkDispatchers()

val queue: Queue
get() = override() ?: QueueImpl.getInstanceOrCreate {
QueueImpl(dispatcher = Dispatchers.IO, queueStorage, queueRunRequest, jsonAdapter, sdkConfig, timer, logger, dateUtil)
get() = override() ?: getSingletonInstanceCreate {
QueueImpl(dispatchersProvider, queueStorage, queueRunRequest, jsonAdapter, sdkConfig, timer, logger, dateUtil)
}

internal val cleanupRepository: CleanupRepository
get() = override() ?: CleanupRepositoryImpl(queue)

val queueQueryRunner: QueueQueryRunner
get() = override() ?: QueueQueryRunnerImpl(logger)

Expand All @@ -88,7 +95,7 @@ class CustomerIOComponent(
get() = override() ?: DateUtilImpl()

val timer: SimpleTimer
get() = override() ?: AndroidSimpleTimer(logger, uiDispatcher = Dispatchers.Main)
get() = override() ?: AndroidSimpleTimer(logger, dispatchersProvider)

val trackRepository: TrackRepository
get() = override() ?: TrackRepositoryImpl(sharedPreferenceRepository, queue, logger)
Expand Down
31 changes: 31 additions & 0 deletions sdk/src/main/java/io/customer/sdk/di/DiGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,35 @@ abstract class DiGraph {
* Or you may not be able to successfully mock in test functions.
*/
inline fun <reified DEP> override(): DEP? = overrides[DEP::class.java.simpleName] as? DEP

/**
* We prefer to have all of the SDK's singleton instances held in the dependency injection graph. This makes it easier for automated tests to be able to delete all
* singletons between each test function and prevent test flakiness.
*/
val singletons: MutableMap<String, Any> = mutableMapOf()

/**
* In the graph, if you have any dependency that should be a singleton:
* ```
* val queue: Queue
* get() = override() ?: getSingletonInstanceCreate {
* QueueImpl(...)
* }
* ```
*/
inline fun <reified INST : Any> getSingletonInstanceCreate(newInstanceCreator: () -> INST): INST {
val singletonKey = INST::class.java.simpleName

return singletons[singletonKey] as? INST ?: newInstanceCreator().also {
singletons[singletonKey] = it
}
}

/**
* Call to delete instances held by the graph. This is meant to be called in between automated tests but can also be called to reset that state of the SDK at runtime.
*/
fun reset() {
overrides.clear()
singletons.clear()
}
}
15 changes: 9 additions & 6 deletions sdk/src/main/java/io/customer/sdk/queue/Queue.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,11 @@ import io.customer.sdk.queue.type.QueueStatus
import io.customer.sdk.queue.type.QueueTaskGroup
import io.customer.sdk.queue.type.QueueTaskType
import io.customer.sdk.util.DateUtil
import io.customer.sdk.util.DispatchersProvider
import io.customer.sdk.util.JsonAdapter
import io.customer.sdk.util.Logger
import io.customer.sdk.util.Seconds
import io.customer.sdk.util.SimpleTimer
import io.customer.sdk.util.Singleton
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

Expand All @@ -40,10 +39,12 @@ interface Queue {
): QueueModifyResult

suspend fun run()

fun deleteExpiredTasks()
}

class QueueImpl internal constructor(
private val dispatcher: CoroutineDispatcher,
private val dispatchersProvider: DispatchersProvider,
private val storage: QueueStorage,
private val runRequest: QueueRunRequest,
private val jsonAdapter: JsonAdapter,
Expand All @@ -53,8 +54,6 @@ class QueueImpl internal constructor(
private val dateUtil: DateUtil
) : Queue {

companion object SingletonHolder : Singleton<Queue>()

private val numberSecondsToScheduleTimer: Seconds
get() = Seconds(sdkConfig.backgroundQueueSecondsDelay)

Expand Down Expand Up @@ -106,7 +105,7 @@ class QueueImpl internal constructor(
}

internal fun runAsync() {
CoroutineScope(dispatcher).launch {
CoroutineScope(dispatchersProvider.background).launch {
run()
}
}
Expand Down Expand Up @@ -211,4 +210,8 @@ class QueueImpl internal constructor(
blockingGroups = blockingGroups
)
}

override fun deleteExpiredTasks() {
storage.deleteExpired()
}
}
Loading

0 comments on commit 8dca8b7

Please sign in to comment.