Skip to content

Commit 50e7aed

Browse files
authored
Merge pull request #1617 from DimensionDev/bugfix/opml_import_crash
refactor: improve OPML import concurrency handling and add stress test
2 parents 1a13419 + 67252bd commit 50e7aed

File tree

2 files changed

+69
-24
lines changed

2 files changed

+69
-24
lines changed

shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/rss/ImportOPMLPresenter.kt

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ import dev.dimension.flare.ui.model.mapper.render
1818
import dev.dimension.flare.ui.presenter.PresenterBase
1919
import kotlinx.collections.immutable.ImmutableList
2020
import kotlinx.collections.immutable.toImmutableList
21-
import kotlinx.coroutines.async
22-
import kotlinx.coroutines.awaitAll
21+
import kotlinx.coroutines.flow.channelFlow
22+
import kotlinx.coroutines.launch
2323
import org.koin.core.component.KoinComponent
2424
import org.koin.core.component.inject
2525
import kotlin.time.Clock
@@ -58,17 +58,17 @@ public class ImportOPMLPresenter(
5858

5959
totalCount = outlines.size
6060

61-
val sources =
61+
channelFlow {
6262
outlines
6363
.filter { it.xmlUrl != null }
64-
.map { outline ->
65-
async {
66-
val url = outline.xmlUrl ?: return@async null
64+
.forEach { outline ->
65+
launch {
66+
val url = outline.xmlUrl ?: return@launch
6767

6868
val existing = appDatabase.rssSourceDao().getByUrl(url)
6969
if (existing.isNotEmpty()) {
70-
importedSources.add(existing.first().render())
71-
return@async null
70+
send(existing.first().render())
71+
return@launch
7272
}
7373

7474
val icon =
@@ -78,19 +78,22 @@ public class ImportOPMLPresenter(
7878
null
7979
}
8080

81-
DbRssSources(
82-
url = url,
83-
title = outline.title ?: outline.text,
84-
icon = icon,
85-
lastUpdate = Clock.System.now().toEpochMilliseconds(),
86-
).apply {
87-
importedSources.add(this.render())
88-
}
89-
}
90-
}.awaitAll()
91-
.filterNotNull()
81+
val newSource =
82+
DbRssSources(
83+
url = url,
84+
title = outline.title ?: outline.text,
85+
icon = icon,
86+
lastUpdate = Clock.System.now().toEpochMilliseconds(),
87+
)
9288

93-
appDatabase.rssSourceDao().insertAll(sources)
89+
appDatabase.rssSourceDao().insert(newSource)
90+
91+
send(newSource.render())
92+
}
93+
}
94+
}.collect { source ->
95+
importedSources.add(source)
96+
}
9497
} catch (e: Exception) {
9598
error = e.message
9699
} finally {

shared/src/commonTest/kotlin/dev/dimension/flare/ui/presenter/home/rss/ImportOPMLPresenterTest.kt

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,6 @@ class ImportOPMLPresenterTest {
8686

8787
val finalState = states.last()
8888

89-
println(
90-
"Final state: importing=${finalState.importing}, error=${finalState.error}, total=${finalState.totalCount}, imported=${finalState.importedCount}",
91-
)
92-
9389
assertFalse(finalState.importing)
9490
assertNull(finalState.error)
9591
assertEquals(2, finalState.totalCount)
@@ -166,4 +162,50 @@ class ImportOPMLPresenterTest {
166162
val df = sources.find { it.url == "https://daringfireball.net/feeds/main" }
167163
assertEquals("Daring Fireball", df?.title)
168164
}
165+
166+
@Test
167+
fun testConcurrencyStressTest() =
168+
runTest {
169+
val feedCount = 1000
170+
val sb = StringBuilder()
171+
sb.append("""<opml version="2.0"><head><title>Stress Test</title></head><body>""")
172+
173+
repeat(feedCount) { i ->
174+
sb.append(
175+
"""
176+
<outline type="rss" text="Feed $i" title="Title $i" xmlUrl="https://stress.test/feed/$i.xml" />
177+
""".trimIndent(),
178+
)
179+
}
180+
sb.append("</body></opml>")
181+
182+
val presenter =
183+
ImportOPMLPresenter(sb.toString()) { url ->
184+
kotlinx.coroutines.delay((1..10).random().toLong())
185+
"https://icon.url/icon.png"
186+
}
187+
188+
val states = mutableListOf<ImportOPMLPresenter.State>()
189+
190+
val job =
191+
launch {
192+
moleculeFlow(mode = RecompositionMode.Immediate) {
193+
presenter.body()
194+
}.collect {
195+
states.add(it)
196+
}
197+
}
198+
199+
advanceUntilIdle()
200+
job.cancel()
201+
202+
val finalState = states.last()
203+
assertNull(finalState.error, "Should verify concurrency without errors")
204+
assertFalse(finalState.importing, "Should finish importing")
205+
assertEquals(feedCount, finalState.totalCount, "Total count should match input")
206+
assertEquals(feedCount, finalState.importedCount, "Imported count should match input")
207+
assertEquals(feedCount, finalState.importedSources.size, "UiState list size should match input")
208+
val dbSources = db.rssSourceDao().getAll().first()
209+
assertEquals(feedCount, dbSources.size, "Database records should match input")
210+
}
169211
}

0 commit comments

Comments
 (0)