-
Notifications
You must be signed in to change notification settings - Fork 136
[POS - Historical Orders] In-memory Cache #14569
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 17 commits
e1aab9d
3f9d131
a77834d
659ad10
4a5f936
2bc7118
0a349b7
7f2acde
fe1c4b5
d4d8169
2539408
a8ef33d
96c3a09
a57f5d7
333effb
60d1cf9
69cc4dd
e0e5325
0cadb0c
fe798f6
10c135c
eab3bc7
1d91751
02fa76f
a57941c
9720e3c
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,41 @@ | ||
| package com.woocommerce.android.ui.woopos.common.data | ||
|
|
||
| import com.woocommerce.android.model.Order | ||
| import com.woocommerce.android.ui.woopos.common.data.searchbyidentifier.WooPosOrdersCache | ||
| import com.woocommerce.android.ui.woopos.common.data.searchbyidentifier.WooPosOrdersCache.Companion.MAX_CACHE_SIZE | ||
| import kotlinx.coroutines.sync.Mutex | ||
| import kotlinx.coroutines.sync.withLock | ||
| import javax.inject.Inject | ||
|
|
||
| class WooPosOrdersInMemoryCache @Inject constructor() : WooPosOrdersCache { | ||
| private val mutex = Mutex() | ||
|
|
||
| companion object { | ||
| private const val INITIAL_CAPACITY = MAX_CACHE_SIZE | ||
|
||
| private const val LOAD_FACTOR = 0.75f | ||
| } | ||
|
|
||
| private val ordersCache = LinkedHashMap<Long, Order>(INITIAL_CAPACITY, LOAD_FACTOR, true) | ||
|
||
|
|
||
| override suspend fun addAll(orders: List<Order>) = mutex.withLock { | ||
|
||
| addAllInternal(orders) | ||
| } | ||
|
|
||
| override suspend fun getAll(): List<Order> = mutex.withLock { | ||
| ordersCache.values.toList() | ||
| } | ||
|
|
||
| override suspend fun clear() = mutex.withLock { | ||
| ordersCache.clear() | ||
| } | ||
|
|
||
| private fun addAllInternal(orders: List<Order>) { | ||
| orders.forEach { order -> | ||
| ordersCache[order.id] = order | ||
| if (ordersCache.size > MAX_CACHE_SIZE) { | ||
| val keysToRemove = ordersCache.keys.take(ordersCache.size - MAX_CACHE_SIZE) | ||
| keysToRemove.forEach { ordersCache.remove(it) } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.woocommerce.android.ui.woopos.common.data.searchbyidentifier | ||
|
|
||
| import com.woocommerce.android.model.Order | ||
|
|
||
| interface WooPosOrdersCache { | ||
|
||
| companion object { | ||
| const val MAX_CACHE_SIZE = 25 | ||
| } | ||
|
|
||
| suspend fun addAll(orders: List<Order>) | ||
|
|
||
| suspend fun getAll(): List<Order> | ||
|
|
||
| suspend fun clear() | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,9 @@ package com.woocommerce.android.ui.woopos.orders | |
| import com.woocommerce.android.model.Order | ||
| import com.woocommerce.android.model.OrderMapper | ||
| import com.woocommerce.android.tools.SelectedSite | ||
| import com.woocommerce.android.ui.woopos.common.data.searchbyidentifier.WooPosOrdersCache | ||
| import kotlinx.coroutines.flow.Flow | ||
| import kotlinx.coroutines.flow.flow | ||
| import org.wordpress.android.fluxc.network.rest.wpcom.wc.order.OrderRestClient | ||
| import org.wordpress.android.fluxc.network.rest.wpcom.wc.order.OrderRestClient.OrderBy | ||
| import org.wordpress.android.fluxc.persistence.entity.OrderEntity | ||
|
|
@@ -17,8 +20,12 @@ class WooPosOrdersDataSource @Inject constructor( | |
| private val restClient: OrderRestClient, | ||
| private val selectedSite: SelectedSite, | ||
| private val orderMapper: OrderMapper, | ||
| private val ordersCache: WooPosOrdersCache | ||
| ) { | ||
| suspend fun loadOrders(): LoadOrdersResult { | ||
| fun loadOrders(): Flow<LoadOrdersResult> = flow { | ||
| val cached = ordersCache.getAll() | ||
| emit(LoadOrdersResult.Success(cached)) | ||
|
|
||
| val result = restClient.fetchOrders( | ||
| site = selectedSite.get(), | ||
| count = 25, | ||
|
|
@@ -29,14 +36,16 @@ class WooPosOrdersDataSource @Inject constructor( | |
| createdVia = "pos-rest-api" | ||
| ) | ||
|
|
||
| return if (result.isError) { | ||
| LoadOrdersResult.Error(result.error.message) | ||
| if (result.isError) { | ||
| emit(LoadOrdersResult.Error(result.error.message)) | ||
| } else { | ||
| LoadOrdersResult.Success(result.orders.toAppModels()) | ||
| val mapped = result.orders.toAppModels() | ||
| ordersCache.addAll(mapped) | ||
| emit(LoadOrdersResult.Success(result.orders.toAppModels())) | ||
| } | ||
| } | ||
|
|
||
| private suspend fun List<OrderEntity>.toAppModels(): List<Order> = map { | ||
| private suspend fun List<OrderEntity>?.toAppModels(): List<Order> = this?.map { | ||
|
||
| orderMapper.toAppModel(it) | ||
| } | ||
| } ?: emptyList() | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,25 +30,28 @@ class WooPosOrdersViewModel @Inject constructor( | |
| viewModelScope.launch { | ||
| _state.update { it.copy(isLoading = true, error = null) } | ||
|
|
||
| when (val result = ordersDataSource.loadOrders()) { | ||
| is LoadOrdersResult.Error -> { | ||
| _state.update { | ||
| it.copy( | ||
| isLoading = false, | ||
| error = result.message ?: "Unknown error" | ||
| ) | ||
| ordersDataSource.loadOrders().collect { result -> | ||
| when (result) { | ||
| is LoadOrdersResult.Error -> { | ||
| _state.update { | ||
| it.copy( | ||
| isLoading = false, | ||
| error = result.message ?: "Unknown error" | ||
|
||
| ) | ||
| } | ||
| } | ||
| } | ||
| is LoadOrdersResult.Success -> { | ||
| val list = result.orders | ||
| _state.update { prev -> | ||
| prev.copy( | ||
| isLoading = false, | ||
| orders = list, | ||
| selectedOrderId = prev.selectedOrderId?.takeIf { id -> | ||
| list.any { o -> o.id == id } | ||
| } ?: list.firstOrNull()?.id | ||
| ) | ||
|
|
||
| is LoadOrdersResult.Success -> { | ||
| val list = result.orders | ||
| _state.update { prev -> | ||
| prev.copy( | ||
| isLoading = false, | ||
| orders = list, | ||
| selectedOrderId = prev.selectedOrderId?.takeIf { id -> | ||
| list.any { o -> o.id == id } | ||
| } ?: list.firstOrNull()?.id | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,11 +14,13 @@ import androidx.core.view.WindowInsetsCompat | |
| import com.woocommerce.android.ui.woopos.cardreader.WooPosCardReaderFacade | ||
| import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosSpacing | ||
| import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTheme | ||
| import com.woocommerce.android.ui.woopos.common.data.searchbyidentifier.WooPosOrdersCache | ||
| import com.woocommerce.android.ui.woopos.home.items.coupons.creation.WooPosCouponCreationFacade | ||
| import com.woocommerce.android.ui.woopos.support.WooPosGetSupportFacade | ||
| import com.woocommerce.android.ui.woopos.util.analytics.WooPosAnalyticsTracker | ||
| import com.woocommerce.android.ui.woopos.util.ext.isGestureNavigation | ||
| import dagger.hilt.android.AndroidEntryPoint | ||
| import kotlinx.coroutines.runBlocking | ||
| import javax.inject.Inject | ||
|
|
||
| @AndroidEntryPoint | ||
|
|
@@ -35,6 +37,9 @@ class WooPosActivity : AppCompatActivity() { | |
| @Inject | ||
| lateinit var wooPosCouponCreationFacade: WooPosCouponCreationFacade | ||
|
|
||
| @Inject | ||
| lateinit var ordersCache: WooPosOrdersCache | ||
|
|
||
| override fun onCreate(savedInstanceState: Bundle?) { | ||
| super.onCreate(savedInstanceState) | ||
| WindowCompat.setDecorFitsSystemWindows(window, false) | ||
|
|
@@ -53,6 +58,13 @@ class WooPosActivity : AppCompatActivity() { | |
| } | ||
| } | ||
| } | ||
|
|
||
| override fun onDestroy() { | ||
| runBlocking { | ||
|
||
| ordersCache.clear() | ||
| } | ||
| super.onDestroy() | ||
| } | ||
| } | ||
|
|
||
| @Composable | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| package com.woocommerce.android.ui.woopos.common.data | ||
|
|
||
| import com.woocommerce.android.ui.orders.OrderTestUtils | ||
| import kotlinx.coroutines.ExperimentalCoroutinesApi | ||
| import kotlinx.coroutines.async | ||
| import kotlinx.coroutines.awaitAll | ||
| import kotlinx.coroutines.test.runTest | ||
| import org.assertj.core.api.Assertions.assertThat | ||
| import org.junit.Before | ||
| import org.junit.Test | ||
|
|
||
| @ExperimentalCoroutinesApi | ||
| class WooPosOrdersInMemoryCacheTest { | ||
|
|
||
| private lateinit var cache: WooPosOrdersInMemoryCache | ||
|
|
||
| @Before | ||
| fun setup() { | ||
| cache = WooPosOrdersInMemoryCache() | ||
| } | ||
|
|
||
| @Test | ||
| fun `when cache is empty, then getAll returns empty list`() = runTest { | ||
| // WHEN | ||
| val result = cache.getAll() | ||
|
|
||
| // THEN | ||
| assertThat(result).isEmpty() | ||
| } | ||
|
|
||
| @Test | ||
| fun `when orders are added, then getAll returns all orders`() = runTest { | ||
| // GIVEN | ||
| val orders = listOf(OrderTestUtils.generateTestOrder(1), OrderTestUtils.generateTestOrder(2)) | ||
|
|
||
| // WHEN | ||
| cache.addAll(orders) | ||
| val result = cache.getAll() | ||
|
|
||
| // THEN | ||
| assertThat(result).hasSize(2) | ||
| assertThat(result).containsExactlyInAnyOrderElementsOf(orders) | ||
| } | ||
|
|
||
| @Test | ||
| fun `when cache is cleared, then getAll returns empty list`() = runTest { | ||
| // GIVEN | ||
| val orders = listOf(OrderTestUtils.generateTestOrder(1), OrderTestUtils.generateTestOrder(2)) | ||
| cache.addAll(orders) | ||
|
|
||
| // WHEN | ||
| cache.clear() | ||
| val result = cache.getAll() | ||
|
|
||
| // THEN | ||
| assertThat(result).isEmpty() | ||
| } | ||
|
|
||
| @Test | ||
| fun `when adding order with existing id, then it should replace old order`() = runTest { | ||
| // GIVEN | ||
| val order1 = OrderTestUtils.generateTestOrder(1) | ||
| val order1Updated = OrderTestUtils.generateTestOrder(1).copy(currency = "TEST") | ||
|
|
||
| // WHEN | ||
| cache.addAll(listOf(order1)) | ||
| cache.addAll(listOf(order1Updated)) | ||
| val result = cache.getAll().first { it.id == 1L } | ||
|
|
||
| // THEN | ||
| assertThat(result).isEqualTo(order1Updated) | ||
| assertThat(result.currency).isEqualTo("TEST") | ||
| } | ||
|
|
||
| @Test | ||
| fun `when adding more orders than max cache size, then oldest orders should be removed`() = runTest { | ||
| // GIVEN | ||
| val orders = (1..10005L).map { OrderTestUtils.generateTestOrder(it) } | ||
|
|
||
| // WHEN | ||
| cache.addAll(orders) | ||
| val firstOrder = cache.getAll().find { it.id == 1L } | ||
| val lastOrder = cache.getAll().find { it.id == 10005L } | ||
|
|
||
| // THEN | ||
| assertThat(firstOrder).isNull() | ||
| assertThat(lastOrder).isNotNull | ||
| assertThat(cache.getAll()).hasSize(25) | ||
| } | ||
|
|
||
| @Test | ||
| fun `when multiple threads access cache concurrently, then data remains consistent`() = runTest { | ||
| // GIVEN | ||
| val initialOrders = (1..100L).map { OrderTestUtils.generateTestOrder(it) } | ||
| cache.addAll(initialOrders) | ||
|
|
||
| // WHEN - simulate concurrent access | ||
| val concurrentOperations = (101..200L).map { id -> | ||
| async { | ||
| val order = OrderTestUtils.generateTestOrder(id) | ||
| cache.addAll(listOf(order)) | ||
| cache.getAll().find { it.id == id } | ||
| } | ||
| } | ||
|
|
||
| // THEN | ||
| val results = concurrentOperations.awaitAll() | ||
| assertThat(results).hasSize(100) | ||
| assertThat(results).doesNotContainNull() | ||
|
|
||
| val allCachedOrders = cache.getAll() | ||
| // Only MAX_CACHE_SIZE should remain | ||
| assertThat(allCachedOrders).hasSize(25) | ||
|
|
||
| // The most recent 25 orders should be present | ||
| val expectedIds = (176L..200L).toList() | ||
| assertThat(allCachedOrders.map { it.id }).containsExactlyInAnyOrderElementsOf(expectedIds) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's better to place it into
woopos.orders. The product-related code here is because it's used in the list and the search.