Skip to content

Commit ebdb858

Browse files
authored
Merge pull request #608 from android/tunjid-batch-sync
Batch sync news resources from remote
2 parents 6d1f85b + 8765d3a commit ebdb858

File tree

4 files changed

+80
-68
lines changed

4 files changed

+80
-68
lines changed

core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ import kotlinx.coroutines.flow.Flow
3434
import kotlinx.coroutines.flow.map
3535
import javax.inject.Inject
3636

37+
// Heuristic value to optimize for serialization and deserialization cost on client and server
38+
// for each news resource batch.
39+
private const val SYNC_BATCH_SIZE = 40
40+
3741
/**
3842
* Disk storage backed implementation of the [NewsRepository].
3943
* Reads are exclusively from local storage to support offline access.
@@ -65,26 +69,29 @@ class OfflineFirstNewsRepository @Inject constructor(
6569
},
6670
modelDeleter = newsResourceDao::deleteNewsResources,
6771
modelUpdater = { changedIds ->
68-
val networkNewsResources = network.getNewsResources(ids = changedIds)
72+
changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds ->
73+
val networkNewsResources = network.getNewsResources(ids = chunkedIds)
6974

70-
// Order of invocation matters to satisfy id and foreign key constraints!
75+
// Order of invocation matters to satisfy id and foreign key constraints!
7176

72-
topicDao.insertOrIgnoreTopics(
73-
topicEntities = networkNewsResources
74-
.map(NetworkNewsResource::topicEntityShells)
75-
.flatten()
76-
.distinctBy(TopicEntity::id),
77-
)
78-
newsResourceDao.upsertNewsResources(
79-
newsResourceEntities = networkNewsResources
80-
.map(NetworkNewsResource::asEntity),
81-
)
82-
newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
83-
newsResourceTopicCrossReferences = networkNewsResources
84-
.map(NetworkNewsResource::topicCrossReferences)
85-
.distinct()
86-
.flatten(),
87-
)
77+
topicDao.insertOrIgnoreTopics(
78+
topicEntities = networkNewsResources
79+
.map(NetworkNewsResource::topicEntityShells)
80+
.flatten()
81+
.distinctBy(TopicEntity::id),
82+
)
83+
newsResourceDao.upsertNewsResources(
84+
newsResourceEntities = networkNewsResources.map(
85+
NetworkNewsResource::asEntity,
86+
),
87+
)
88+
newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
89+
newsResourceTopicCrossReferences = networkNewsResources
90+
.map(NetworkNewsResource::topicCrossReferences)
91+
.distinct()
92+
.flatten(),
93+
)
94+
}
8895
},
8996
)
9097
}

core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestTopicDao
2727
import com.google.samples.apps.nowinandroid.core.data.testdoubles.filteredInterestsIds
2828
import com.google.samples.apps.nowinandroid.core.data.testdoubles.nonPresentInterestsIds
2929
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
30+
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
3031
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
3132
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
3233
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
@@ -111,8 +112,8 @@ class OfflineFirstNewsRepositoryTest {
111112
)
112113

113114
assertEquals(
114-
emptyList(),
115-
subject.getNewsResources(
115+
expected = emptyList(),
116+
actual = subject.getNewsResources(
116117
query = NewsResourceQuery(
117118
filterTopicIds = nonPresentInterestsIds,
118119
),
@@ -135,14 +136,14 @@ class OfflineFirstNewsRepositoryTest {
135136
.map(PopulatedNewsResource::asExternalModel)
136137

137138
assertEquals(
138-
newsResourcesFromNetwork.map(NewsResource::id),
139-
newsResourcesFromDb.map(NewsResource::id),
139+
newsResourcesFromNetwork.map(NewsResource::id).sorted(),
140+
newsResourcesFromDb.map(NewsResource::id).sorted(),
140141
)
141142

142143
// After sync version should be updated
143144
assertEquals(
144-
network.latestChangeListVersion(CollectionType.NewsResources),
145-
synchronizer.getChangeListVersions().newsResourceVersion,
145+
expected = network.latestChangeListVersion(CollectionType.NewsResources),
146+
actual = synchronizer.getChangeListVersions().newsResourceVersion,
146147
)
147148
}
148149

@@ -176,14 +177,14 @@ class OfflineFirstNewsRepositoryTest {
176177

177178
// Assert that items marked deleted on the network have been deleted locally
178179
assertEquals(
179-
newsResourcesFromNetwork.map(NewsResource::id) - deletedItems,
180-
newsResourcesFromDb.map(NewsResource::id),
180+
expected = (newsResourcesFromNetwork.map(NewsResource::id) - deletedItems).sorted(),
181+
actual = newsResourcesFromDb.map(NewsResource::id).sorted(),
181182
)
182183

183184
// After sync version should be updated
184185
assertEquals(
185-
network.latestChangeListVersion(CollectionType.NewsResources),
186-
synchronizer.getChangeListVersions().newsResourceVersion,
186+
expected = network.latestChangeListVersion(CollectionType.NewsResources),
187+
actual = synchronizer.getChangeListVersions().newsResourceVersion,
187188
)
188189
}
189190

@@ -215,14 +216,14 @@ class OfflineFirstNewsRepositoryTest {
215216
.map(PopulatedNewsResource::asExternalModel)
216217

217218
assertEquals(
218-
newsResourcesFromNetwork.map(NewsResource::id),
219-
newsResourcesFromDb.map(NewsResource::id),
219+
expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(),
220+
actual = newsResourcesFromDb.map(NewsResource::id).sorted(),
220221
)
221222

222223
// After sync version should be updated
223224
assertEquals(
224-
changeList.last().changeListVersion,
225-
synchronizer.getChangeListVersions().newsResourceVersion,
225+
expected = changeList.last().changeListVersion,
226+
actual = synchronizer.getChangeListVersions().newsResourceVersion,
226227
)
227228
}
228229

@@ -232,12 +233,14 @@ class OfflineFirstNewsRepositoryTest {
232233
subject.syncWith(synchronizer)
233234

234235
assertEquals(
235-
network.getNewsResources()
236+
expected = network.getNewsResources()
236237
.map(NetworkNewsResource::topicEntityShells)
237238
.flatten()
238-
.distinctBy(TopicEntity::id),
239-
topicDao.getTopicEntities()
240-
.first(),
239+
.distinctBy(TopicEntity::id)
240+
.sortedBy(TopicEntity::toString),
241+
actual = topicDao.getTopicEntities()
242+
.first()
243+
.sortedBy(TopicEntity::toString),
241244
)
242245
}
243246

@@ -247,11 +250,13 @@ class OfflineFirstNewsRepositoryTest {
247250
subject.syncWith(synchronizer)
248251

249252
assertEquals(
250-
network.getNewsResources()
253+
expected = network.getNewsResources()
251254
.map(NetworkNewsResource::topicCrossReferences)
255+
.flatten()
252256
.distinct()
253-
.flatten(),
254-
newsResourceDao.topicCrossReferences,
257+
.sortedBy(NewsResourceTopicCrossRef::toString),
258+
actual = newsResourceDao.topicCrossReferences
259+
.sortedBy(NewsResourceTopicCrossRef::toString),
255260
)
256261
}
257262
}

core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,10 @@ import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEnti
2121
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
2222
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource
2323
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
24-
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
2524
import kotlinx.coroutines.flow.Flow
2625
import kotlinx.coroutines.flow.MutableStateFlow
2726
import kotlinx.coroutines.flow.map
2827
import kotlinx.coroutines.flow.update
29-
import kotlinx.datetime.Instant
3028

3129
val filteredInterestsIds = setOf("1")
3230
val nonPresentInterestsIds = setOf("2")
@@ -37,17 +35,7 @@ val nonPresentInterestsIds = setOf("2")
3735
class TestNewsResourceDao : NewsResourceDao {
3836

3937
private var entitiesStateFlow = MutableStateFlow(
40-
listOf(
41-
NewsResourceEntity(
42-
id = "1",
43-
title = "news",
44-
content = "Hilt",
45-
url = "url",
46-
headerImageUrl = "headerImageUrl",
47-
type = Video,
48-
publishDate = Instant.fromEpochMilliseconds(1),
49-
),
50-
),
38+
emptyList<NewsResourceEntity>(),
5139
)
5240

5341
internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf()
@@ -78,7 +66,14 @@ class TestNewsResourceDao : NewsResourceDao {
7866
override suspend fun insertOrIgnoreNewsResources(
7967
entities: List<NewsResourceEntity>,
8068
): List<Long> {
81-
entitiesStateFlow.value = entities
69+
entitiesStateFlow.update { oldValues ->
70+
// Old values come first so new values don't overwrite them
71+
(oldValues + entities)
72+
.distinctBy(NewsResourceEntity::id)
73+
.sortedWith(
74+
compareBy(NewsResourceEntity::publishDate).reversed(),
75+
)
76+
}
8277
// Assume no conflicts on insert
8378
return entities.map { it.id.toLong() }
8479
}
@@ -88,13 +83,22 @@ class TestNewsResourceDao : NewsResourceDao {
8883
}
8984

9085
override suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>) {
91-
entitiesStateFlow.value = newsResourceEntities
86+
entitiesStateFlow.update { oldValues ->
87+
// New values come first so they overwrite old values
88+
(newsResourceEntities + oldValues)
89+
.distinctBy(NewsResourceEntity::id)
90+
.sortedWith(
91+
compareBy(NewsResourceEntity::publishDate).reversed(),
92+
)
93+
}
9294
}
9395

9496
override suspend fun insertOrIgnoreTopicCrossRefEntities(
9597
newsResourceTopicCrossReferences: List<NewsResourceTopicCrossRef>,
9698
) {
97-
topicCrossReferences = newsResourceTopicCrossReferences
99+
// Keep old values over new ones
100+
topicCrossReferences = (topicCrossReferences + newsResourceTopicCrossReferences)
101+
.distinctBy { it.newsResourceId to it.topicId }
98102
}
99103

100104
override suspend fun deleteNewsResources(ids: List<String>) {

core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,7 @@ import kotlinx.coroutines.flow.update
2929
class TestTopicDao : TopicDao {
3030

3131
private var entitiesStateFlow = MutableStateFlow(
32-
listOf(
33-
TopicEntity(
34-
id = "1",
35-
name = "Topic",
36-
shortDescription = "short description",
37-
longDescription = "long description",
38-
url = "URL",
39-
imageUrl = "image URL",
40-
),
41-
),
32+
emptyList<TopicEntity>(),
4233
)
4334

4435
override fun getTopicEntity(topicId: String): Flow<TopicEntity> {
@@ -53,8 +44,10 @@ class TestTopicDao : TopicDao {
5344
.map { topics -> topics.filter { it.id in ids } }
5445

5546
override suspend fun insertOrIgnoreTopics(topicEntities: List<TopicEntity>): List<Long> {
56-
entitiesStateFlow.value = topicEntities
57-
// Assume no conflicts on insert
47+
// Keep old values over new values
48+
entitiesStateFlow.update { oldValues ->
49+
(oldValues + topicEntities).distinctBy(TopicEntity::id)
50+
}
5851
return topicEntities.map { it.id.toLong() }
5952
}
6053

@@ -63,7 +56,10 @@ class TestTopicDao : TopicDao {
6356
}
6457

6558
override suspend fun upsertTopics(entities: List<TopicEntity>) {
66-
entitiesStateFlow.value = entities
59+
// Overwrite old values with new values
60+
entitiesStateFlow.update { oldValues ->
61+
(entities + oldValues).distinctBy(TopicEntity::id)
62+
}
6763
}
6864

6965
override suspend fun deleteTopics(ids: List<String>) {

0 commit comments

Comments
 (0)