Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package com.woocommerce.android.ui.woopos.localcatalog

import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper
import kotlinx.coroutines.delay
import org.wordpress.android.fluxc.model.SiteModel
import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosGenerateCatalogResult
import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosGenerateCatalogState
import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosLocalCatalogStore
import javax.inject.Inject
import kotlin.math.pow

class WooPosFileBasedSyncAction @Inject constructor(
private val posLocalCatalogStore: WooPosLocalCatalogStore,
private val logger: WooPosLogWrapper,
) {
companion object {
private const val INITIAL_POLL_INTERVAL_MS = 3000L
private const val MAX_POLL_ATTEMPTS = 20
private const val MAX_CONSECUTIVE_FAILED_ATTEMPTS = 3

// Backoff settings
private const val MAX_POLL_INTERVAL_MS = 30_000L
private const val BACKOFF_MULTIPLIER = 1.3
}

@Suppress("ReturnCount")
suspend fun syncCatalog(
site: SiteModel
): Result<FileBasedSyncResult> {
logger.d("Starting file-based catalog generation for site ${site.id}")

var attemptCount = 0
var failedConsecutiveAttempts = 0

while (attemptCount < MAX_POLL_ATTEMPTS) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 n.p.: repeat(MAX_POLL_ATTEMPTS) { attemptIndex -> ... } would be more idiomatic, a bit easier to understand, and maintain the code. However, keep in mind that this suggestion may just be my personal taste, and feel free to ignore it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, replaced in 60307fc

attemptCount++

if (attemptCount > 1) {
val delayMs = computeBackoffDelay(attemptCount)
logger.d("Waiting ${delayMs}ms before poll attempt $attemptCount")
delay(delayMs)
}

val response = posLocalCatalogStore.generateCatalog(site)

if (response.isFailure) {
if (++failedConsecutiveAttempts >= MAX_CONSECUTIVE_FAILED_ATTEMPTS) {
return Result.failure(response.exceptionOrNull() ?: Exception("Unknown error"))
} else {
logger.w("Poll attempt $attemptCount failed: ${response.exceptionOrNull()?.message}")
continue
}
}
failedConsecutiveAttempts = 0

val result = response.getOrThrow()
logger.d(
"Poll attempt $attemptCount"
)

val processedResult = processPollingResult(result)
if (processedResult != null) {
return processedResult
}
}

logger.e("Catalog generation timed out after $MAX_POLL_ATTEMPTS attempts")
return Result.failure(Exception("Catalog generation timed out"))
}

data class FileBasedSyncResult(
val fileUrl: String,
val productFields: List<String>?,
val variationFields: List<String>?,
val totalProducts: Int?,
val completedAt: String?
)

@Suppress("ReturnCount")
private fun processPollingResult(result: WooPosGenerateCatalogResult): Result<FileBasedSyncResult>? {
return when (result.state) {
WooPosGenerateCatalogState.COMPLETED -> {
val url = result.url
if (url != null) {
logger.d("Catalog available.")
// TBD Download the file or scheduled bg download job
Result.success(createFileBasedSyncResult(result, url))
} else {
logger.e("Catalog generation completed but URL is missing")
Result.failure(Exception("Catalog generation completed but URL is missing"))
}
}
else -> null.also { logger.d("State: ${result.state}, Progress: ${result.progress}/${result.total}") }
}
}

private fun createFileBasedSyncResult(
result: WooPosGenerateCatalogResult,
fileUrl: String
): FileBasedSyncResult {
return FileBasedSyncResult(
fileUrl = fileUrl,
productFields = result.productFields,
variationFields = result.variationFields,
totalProducts = result.total,
completedAt = result.completedAt
)
}

private fun computeBackoffDelay(attemptCount: Int): Long {
val exponent = (attemptCount - 2).coerceAtLeast(0)
val raw = INITIAL_POLL_INTERVAL_MS * BACKOFF_MULTIPLIER.pow(exponent.toDouble())
val finalDelay = raw.coerceAtMost(MAX_POLL_INTERVAL_MS.toDouble())
return finalDelay.toLong()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ class WooPosLocalCatalogSyncRepository @Inject constructor(
is PosLocalCatalogSyncResult.Failure.DatabaseError -> SyncErrorType.DATABASE_ERROR
is PosLocalCatalogSyncResult.Failure.InvalidResponse -> SyncErrorType.INVALID_RESPONSE
is PosLocalCatalogSyncResult.Failure.UnexpectedError -> SyncErrorType.UNEXPECTED_ERROR
is PosLocalCatalogSyncResult.Failure.CatalogGenerationTimeout -> SyncErrorType.CATALOG_GENERATION_TIMEOUT
}

analyticsTracker.track(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ constructor(
is PosLocalCatalogSyncResult.Failure.NetworkError,
is PosLocalCatalogSyncResult.Failure.DatabaseError,
is PosLocalCatalogSyncResult.Failure.InvalidResponse,
is PosLocalCatalogSyncResult.Failure.UnexpectedError -> {
is PosLocalCatalogSyncResult.Failure.UnexpectedError,
is PosLocalCatalogSyncResult.Failure.CatalogGenerationTimeout -> {
logger.e("Local catalog FULL sync failed: ${fullSyncResult.error}. Retrying ...")
Result.retry()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ sealed class PosLocalCatalogSyncResult {
class DatabaseError(error: String) : Failure(error)
class InvalidResponse(error: String) : Failure(error)
class UnexpectedError(error: String) : Failure(error)
class CatalogGenerationTimeout(error: String) : Failure(error)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ object WooPosAnalyticsEventConstant {
NETWORK_ERROR("network_error"),
DATABASE_ERROR("database_error"),
INVALID_RESPONSE("invalid_response"),
UNEXPECTED_ERROR("unexpected_error");
UNEXPECTED_ERROR("unexpected_error"),
CATALOG_GENERATION_TIMEOUT("catalog_generation_timeout");

override fun toString(): String {
return value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ enum class FeatureFlag {
BETTER_CUSTOMER_SEARCH_M2,
ORDER_CREATION_AUTO_TAX_RATE,
BOOKINGS_MVP,
POS_REFUNDS;
POS_REFUNDS,
WOO_POS_LOCAL_CATALOG_FILE_APPROACH;

fun isEnabled(context: Context? = null): Boolean {
return when (this) {
Expand All @@ -24,6 +25,8 @@ enum class FeatureFlag {
ORDER_CREATION_AUTO_TAX_RATE,
BOOKINGS_MVP,
POS_REFUNDS -> PackageUtils.isDebugBuild()

WOO_POS_LOCAL_CATALOG_FILE_APPROACH -> false
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package com.woocommerce.android.ui.woopos.localcatalog

import com.woocommerce.android.ui.woopos.common.util.WooPosLogWrapper
import com.woocommerce.android.viewmodel.BaseUnitTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.mock
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.wordpress.android.fluxc.model.SiteModel
import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosGenerateCatalogResult
import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosGenerateCatalogState
import org.wordpress.android.fluxc.store.pos.localcatalog.WooPosLocalCatalogStore

@ExperimentalCoroutinesApi
class WooPosFileBasedSyncActionTest : BaseUnitTest() {
private val posLocalCatalogStore: WooPosLocalCatalogStore = mock()
private val logger: WooPosLogWrapper = mock()

private lateinit var sut: WooPosFileBasedSyncAction
private val site = SiteModel().apply { id = 123 }

@Before
fun setup() {
sut = WooPosFileBasedSyncAction(
posLocalCatalogStore = posLocalCatalogStore,
logger = logger
)
}

@Test
fun `given catalog is already generated, when syncCatalog, then returns success immediately`() =
runTest {
// GIVEN
val initialResult = WooPosGenerateCatalogResult(
url = "url",
state = WooPosGenerateCatalogState.COMPLETED,
)
whenever(posLocalCatalogStore.generateCatalog(site)).thenReturn(Result.success(initialResult))

// WHEN
val result = sut.syncCatalog(site)

// THEN
assertThat(result.isSuccess).isTrue()
verify(posLocalCatalogStore, times(1)).generateCatalog(site)
}

@Test
fun `when polling completes, then returns success`() = runTest {
// GIVEN
val initialResult = WooPosGenerateCatalogResult(
state = WooPosGenerateCatalogState.SCHEDULED,
)
val progressResult = WooPosGenerateCatalogResult(
state = WooPosGenerateCatalogState.IN_PROGRESS,
)
val completedResult = WooPosGenerateCatalogResult(
state = WooPosGenerateCatalogState.COMPLETED,
url = "url",
)

whenever(posLocalCatalogStore.generateCatalog(site))
.thenReturn(Result.success(initialResult))
.thenReturn(Result.success(progressResult))
.thenReturn(Result.success(completedResult))

// WHEN
val result = sut.syncCatalog(site)

// THEN
assertThat(result.isSuccess).isTrue()
verify(posLocalCatalogStore, times(3)).generateCatalog(site)
}

@Test
fun `when polling completes without URL, then returns failure`() = runTest {
// GIVEN
val initialResult = WooPosGenerateCatalogResult(
state = WooPosGenerateCatalogState.SCHEDULED
)
val completedWithoutUrl = WooPosGenerateCatalogResult(
state = WooPosGenerateCatalogState.COMPLETED,
url = null
)

whenever(posLocalCatalogStore.generateCatalog(site))
.thenReturn(Result.success(initialResult))
.thenReturn(Result.success(completedWithoutUrl))

// WHEN
val result = sut.syncCatalog(site)

// THEN
assertThat(result.isFailure).isTrue()
}

@Test
fun `given initial request fails, when syncCatalog, then retries and returns success`() = runTest {
// GIVEN
val error = Exception("Network error")
val completed = WooPosGenerateCatalogResult(
state = WooPosGenerateCatalogState.COMPLETED,
url = "url"
)
whenever(posLocalCatalogStore.generateCatalog(site))
.thenReturn(Result.failure(error))
.thenReturn(Result.success(completed))

// WHEN
val result = sut.syncCatalog(site)

// THEN
assertThat(result.isSuccess).isTrue()
}

@Test
fun `given two requests fails, when syncCatalog, then continues until success`() =
runTest {
// GIVEN
val initialResult = WooPosGenerateCatalogResult(state = WooPosGenerateCatalogState.SCHEDULED)
val networkError = Exception("Network error")
val completedResult = WooPosGenerateCatalogResult(
state = WooPosGenerateCatalogState.COMPLETED,
url = "https://example.com/catalog.json"
)

whenever(posLocalCatalogStore.generateCatalog(site))
.thenReturn(Result.success(initialResult))
.thenReturn(Result.failure(networkError))
.thenReturn(Result.failure(networkError))
.thenReturn(Result.success(initialResult))
.thenReturn(Result.success(completedResult))

// WHEN
val result = sut.syncCatalog(site)

// THEN
assertThat(result.isSuccess).isTrue()
verify(posLocalCatalogStore, times(5)).generateCatalog(site)
}

@Test
fun `given 3 consecutive requests fail, when syncCatalog, then returns failure`() = runTest {
// GIVEN
val error = Exception("Network error")
val completed = WooPosGenerateCatalogResult(
state = WooPosGenerateCatalogState.COMPLETED,
url = "url"
)
whenever(posLocalCatalogStore.generateCatalog(site))
.thenReturn(Result.failure(error))
.thenReturn(Result.failure(error))
.thenReturn(Result.failure(error))
.thenReturn(Result.success(completed)) // unreachable

// WHEN
val result = sut.syncCatalog(site)

// THEN
assertThat(result.isFailure).isTrue
}

@Test
fun `when polling max attempts reached, then returns timeout failure`() = runTest {
// GIVEN
val inProgressResult = WooPosGenerateCatalogResult(
state = WooPosGenerateCatalogState.IN_PROGRESS,
progress = 50,
total = 100
)
whenever(posLocalCatalogStore.generateCatalog(site))
.thenReturn(Result.success(inProgressResult))

// WHEN
val result = sut.syncCatalog(site)

// THEN
assertThat(result.isFailure).isTrue()
verify(posLocalCatalogStore, times(20)).generateCatalog(site)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,27 @@ package org.wordpress.android.fluxc.store.pos.localcatalog
data class WooPosGenerateCatalogResult(
val scheduledAt: String? = null,
val completedAt: String? = null,
val state: String? = null,
val state: WooPosGenerateCatalogState,
val progress: Int? = null,
val processed: Int? = null,
val total: Int? = null,
val url: String? = null,
val productFields: List<String>? = null,
val variationFields: List<String>? = null,
)

enum class WooPosGenerateCatalogState(val rawValue: String) {
SCHEDULED("scheduled"),
IN_PROGRESS("in_progress"),
COMPLETED("completed"),
UNKNOWN("");

companion object {
fun from(value: String?): WooPosGenerateCatalogState = when (value) {
"scheduled" -> SCHEDULED
"in_progress" -> IN_PROGRESS
"completed" -> COMPLETED
else -> UNKNOWN
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity: does the backend also return "error" for the catalog generation successful response, or rather an error response type?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it returns 200 it's always scheduled/success/in-progress state.

}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@ class WooPosLocalCatalogStore @Inject constructor(
WooPosGenerateCatalogResult(
scheduledAt = response.model.scheduledAt,
completedAt = response.model.completedAt,
state = response.model.state,
state = WooPosGenerateCatalogState.from(response.model.state),
progress = response.model.progress,
processed = response.model.processed,
total = response.model.total,
Expand Down
Loading