-
Notifications
You must be signed in to change notification settings - Fork 137
Issue/woomob 1867 woo pos implement catalog feed polling mechanism #15093
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 10 commits
33f494e
d56e5d9
ab1948a
10c4c6c
b09815c
7a5fe73
3e1d78e
627bcfe
129a099
46ff6d4
966cbde
d88cdb2
60307fc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) { | ||
|
||
| 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 | ||
malinajirka marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 |
|---|---|---|
| @@ -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 | ||
malinajirka marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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() | ||
| } | ||
malinajirka marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| @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) | ||
malinajirka marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| @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 | ||
malinajirka marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| assertThat(result.isFailure).isTrue | ||
samiuelson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| @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 |
|---|---|---|
|
|
@@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If it returns 200 it's always scheduled/success/in-progress state. |
||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.