Skip to content

Commit 39acad9

Browse files
Roman Andrushchenkoromychab
authored andcommitted
fix: Add background loading for paged loader - while the first page is being loaded
Additional on-fly improvements: - invalidateAllAsync helper extension for all keyed stores - new 'plus' operator for combining StoreResult and any metadata values - throttleLatest flow operator for smoother UI rendering - minor core store refactoring
1 parent 65b5eb3 commit 39acad9

23 files changed

Lines changed: 434 additions & 84 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ The full documentation is available [here](https://docs.uandcode.com/container).
3434
Add the following line to your `build.gradle` file:
3535

3636
```
37-
implementation "com.elveum:container:3.3.0"
37+
implementation "com.elveum:container:3.3.1"
3838
```
3939

4040
## Core Concepts
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.elveum.container
2+
3+
import kotlinx.coroutines.channels.Channel
4+
import kotlinx.coroutines.channels.onClosed
5+
import kotlinx.coroutines.channels.onSuccess
6+
import kotlinx.coroutines.delay
7+
import kotlinx.coroutines.flow.Flow
8+
import kotlinx.coroutines.flow.channelFlow
9+
import kotlinx.coroutines.launch
10+
11+
/**
12+
* Throttles emissions so that consecutive downstream values are never closer
13+
* than [millis] apart, while keeping isolated updates responsive.
14+
*
15+
* Behavior:
16+
* - The first value (and any value that arrives after at least [millis] of
17+
* silence since the previous emission) is emitted **immediately**.
18+
* - While values arrive more frequently than [millis], they are coalesced:
19+
* only the **latest** value is emitted, once per [millis] window.
20+
*
21+
* This gives two benefits at once:
22+
* 1. bursts of frequent updates are optimized down to one emission per window;
23+
* 2. rare, standalone updates are delivered without delay.
24+
*
25+
* Note: for a finite source, completion may be postponed by up to [millis]
26+
* after the last value; this does not affect which values are emitted.
27+
*
28+
* @param T the type of values emitted by the flow.
29+
* @param millis minimum period, in milliseconds, between two downstream emissions.
30+
*/
31+
public fun <T> Flow<T>.throttleLatest(
32+
millis: Long,
33+
): Flow<T> {
34+
val originFlow = this
35+
return channelFlow {
36+
val channel = Channel<T>(capacity = Channel.CONFLATED)
37+
launch {
38+
originFlow.collect { channel.send(it) }
39+
channel.close()
40+
}
41+
while (true) {
42+
channel.receiveCatching()
43+
.onClosed { break }
44+
.onSuccess {
45+
send(it)
46+
delay(millis)
47+
}
48+
}
49+
}
50+
}

container/src/main/java/com/elveum/container/StatefulEmitter.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package com.elveum.container
22

33
public interface StatefulEmitter<T> : Emitter<T> {
44

5+
public val loadConfig: LoadConfig
6+
57
public val hasEmittedValues: Boolean
68

79
public suspend fun emitPendingState()

container/src/main/java/com/elveum/container/subject/lazy/StatefulEmitterImpl.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import java.util.concurrent.atomic.AtomicBoolean
1818
internal class StatefulEmitterImpl<T>(
1919
private val emitter: FlowEmitter<T>,
2020
private val executeParams: ExecuteParams<T>,
21-
private val loadConfig: LoadConfig,
21+
override val loadConfig: LoadConfig,
2222
private val flowCollector: FlowCollector<Container<T>>,
2323
private val flowSubject: FlowSubject<T>?,
2424
) : StatefulEmitter<T>, Emitter<T> by emitter {

container/src/main/java/com/elveum/container/subject/paging/PageEmitter.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.elveum.container.subject.paging
33
import com.elveum.container.ContainerMetadata
44
import com.elveum.container.EmptyMetadata
55
import com.elveum.container.FlowComposer
6+
import com.elveum.container.LoadConfig
67

78
/**
89
* An instance of this emitter is available within the page loader function
@@ -16,6 +17,13 @@ public interface PageEmitter<Key, T> : FlowComposer {
1617
*/
1718
public val metadata: ContainerMetadata
1819

20+
/**
21+
* The requested load configuration (e.g. silent loading or not).
22+
*
23+
* @see LoadConfig
24+
*/
25+
public val loadConfig: LoadConfig
26+
1927
/**
2028
* Emit data loaded for the current page key.
2129
*

container/src/main/java/com/elveum/container/subject/paging/internal/PageContext.kt

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
package com.elveum.container.subject.paging.internal
22

3+
import com.elveum.container.BackgroundLoadMetadata
4+
import com.elveum.container.BackgroundLoadState
35
import com.elveum.container.ContainerMetadata
6+
import com.elveum.container.LoadConfig
47
import com.elveum.container.errorContainer
58
import com.elveum.container.pendingContainer
69
import com.elveum.container.successContainer
10+
import com.elveum.container.update
711
import kotlinx.coroutines.flow.Flow
812
import kotlinx.coroutines.flow.StateFlow
913

1014
internal class PageContext<Key, T>(
1115
private val state: PageRecordsState<Key, T>,
16+
private val loadConfig: LoadConfig,
1217
val isRetry: Boolean,
1318
private val onScheduleNextKey: suspend (Int, Key) -> Unit,
1419
initialRecord: ImmutablePageRecord<Key, T>,
@@ -34,6 +39,15 @@ internal class PageContext<Key, T>(
3439

3540
suspend fun onLoadCompleted() {
3641
state.markAsCompleted(pageIndex, pageKey)
42+
if (loadConfig.isSilentLoadingEnabled) {
43+
state.updateRecord(
44+
pageIndex = pageIndex,
45+
pageKey = pageKey,
46+
container = { old ->
47+
old.update { backgroundLoadState = BackgroundLoadState.Idle }
48+
}
49+
)
50+
}
3751
}
3852

3953
suspend fun onLoadFailed(e: Exception) {
@@ -45,10 +59,22 @@ internal class PageContext<Key, T>(
4559
}
4660

4761
suspend fun onPageDataLoaded(items: List<T>, metadata: ContainerMetadata) {
62+
val container = if (loadConfig.isSilentLoadingEnabled) {
63+
val bgLoadState = if (pageIndex == 0) {
64+
// report background loading only for the first page
65+
BackgroundLoadState.Loading
66+
} else {
67+
BackgroundLoadState.Idle
68+
}
69+
val bgLoadMetadata = BackgroundLoadMetadata(bgLoadState)
70+
successContainer(items, metadata + bgLoadMetadata)
71+
} else {
72+
successContainer(items, metadata)
73+
}
4874
state.updateRecord(
4975
pageIndex = pageIndex,
5076
pageKey = pageKey,
51-
container = { successContainer(items, metadata) },
77+
container = { container },
5278
)
5379
}
5480

container/src/main/java/com/elveum/container/subject/paging/internal/PageEmitterImpl.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.elveum.container.subject.paging.internal
22

33
import com.elveum.container.Container
44
import com.elveum.container.ContainerMetadata
5+
import com.elveum.container.LoadConfig
56
import com.elveum.container.StatefulEmitter
67
import com.elveum.container.subject.paging.PageEmitter
78
import kotlinx.coroutines.flow.Flow
@@ -21,6 +22,7 @@ internal class PageEmitterImpl<Key, T>(
2122
private set
2223

2324
override val metadata: ContainerMetadata get() = originEmitter.metadata
25+
override val loadConfig: LoadConfig get() = originEmitter.loadConfig
2426

2527
override suspend fun emitPage(list: List<T>, metadata: ContainerMetadata) {
2628
isPageEmitted = true

container/src/main/java/com/elveum/container/subject/paging/internal/PageLoadSession.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ internal class PageLoadSession<Key, T>(
3939
val record = state.prepareRecord(pageIndex, pageKey)
4040
val context = PageContext(
4141
state = state,
42+
loadConfig = originEmitter.loadConfig,
4243
initialRecord = record,
4344
isRetry = isRetry,
4445
onScheduleNextKey = { nextIndex, nextKey ->

container/src/main/java/com/elveum/container/subject/paging/internal/PageRecordsState.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
package com.elveum.container.subject.paging.internal
22

33
import com.elveum.container.BackgroundLoadMetadata
4-
import com.elveum.container.BackgroundLoadState
54
import com.elveum.container.Container
65
import com.elveum.container.ContainerMetadata
76
import com.elveum.container.EmptyMetadata
87
import com.elveum.container.StatefulEmitter
8+
import com.elveum.container.get
99
import com.elveum.container.getOrNull
1010
import com.elveum.container.isCompleted
1111
import com.elveum.container.isError
@@ -163,10 +163,12 @@ internal class PageRecordsState<Key, T>(
163163
onNextPageStateChanged(pageState)
164164
val outputList = outputListSnapshot()
165165

166-
val finalMetadata = BackgroundLoadMetadata(BackgroundLoadState.Idle) + if (config.emitMetadata) {
166+
val bgLoadMetadata = containers.firstOrNull()?.metadata?.get<BackgroundLoadMetadata>()
167+
val finalMetadata = if (config.emitMetadata) {
167168
OnItemRenderedCallbackMetadata(onItemRenderedRef) +
168169
NextPageStateMetadata(pageState) +
169-
outputMergedMetadata(containers)
170+
outputMergedMetadata(containers) +
171+
bgLoadMetadata
170172
} else {
171173
EmptyMetadata
172174
}

container/src/main/java/com/elveum/container/subject/paging/internal/ScopedPageLoader.kt

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.elveum.container.StatefulEmitter
66
import kotlinx.coroutines.CompletableDeferred
77
import kotlinx.coroutines.CoroutineScope
88
import kotlinx.coroutines.ExperimentalCoroutinesApi
9+
import kotlinx.coroutines.currentCoroutineContext
910
import kotlinx.coroutines.ensureActive
1011
import kotlinx.coroutines.flow.MutableStateFlow
1112
import kotlinx.coroutines.flow.combine
@@ -27,29 +28,22 @@ internal class ScopedPageLoader<Key, T>(
2728
fun loadPage(context: PageContext<Key, T>) {
2829
coroutineScope.launch {
2930
if (!context.isRetry) awaitFetchDistanceSatisfied(context)
31+
val emitter = PageEmitterImpl(
32+
context = context,
33+
originEmitter = originEmitter,
34+
)
3035
try {
3136
context.onLoadStarted(context.isRetry)
32-
val emitter = PageEmitterImpl(
33-
context = context,
34-
originEmitter = originEmitter,
35-
)
3637
config.block(emitter, context.pageKey)
3738
if (!emitter.isPageEmitted) {
3839
sessionCompleteDeferred.completeExceptionally(
3940
IllegalStateException("emitPage() must be called at least once.")
4041
)
4142
return@launch
4243
}
43-
context.onLoadCompleted()
44-
if (context.isAllPagesCompleted()) {
45-
sessionCompleteDeferred.complete(Unit)
46-
}
44+
completeLoad(context)
4745
} catch (e: Exception) {
48-
ensureActive()
49-
if (context.pageIndex == 0) {
50-
sessionCompleteDeferred.completeExceptionally(e)
51-
}
52-
context.onLoadFailed(e)
46+
handleLoadError(context, emitter.isPageEmitted, e)
5347
}
5448
}
5549
}
@@ -64,6 +58,31 @@ internal class ScopedPageLoader<Key, T>(
6458
}
6559
}
6660

61+
private suspend fun handleLoadError(
62+
context: PageContext<Key, T>,
63+
isPageEmitted: Boolean,
64+
e: Exception,
65+
) {
66+
currentCoroutineContext().ensureActive()
67+
val isSilentErrors = originEmitter.loadConfig.isSilentErrorsEnabled
68+
if (isSilentErrors && isPageEmitted) {
69+
// page data already exists, errors can be suppressed by silent flag
70+
completeLoad(context)
71+
} else {
72+
if (context.pageIndex == 0) {
73+
sessionCompleteDeferred.completeExceptionally(e)
74+
}
75+
context.onLoadFailed(e)
76+
}
77+
}
78+
79+
private suspend fun completeLoad(context: PageContext<Key, T>) {
80+
context.onLoadCompleted()
81+
if (context.isAllPagesCompleted()) {
82+
sessionCompleteDeferred.complete(Unit)
83+
}
84+
}
85+
6786
private suspend fun awaitFetchDistanceSatisfied(context: PageContext<Key, T>) {
6887
if (context.pageIndex == 0) return // immediate load of the initial page
6988

@@ -89,7 +108,8 @@ internal class ScopedPageLoader<Key, T>(
89108
) { indexOfLastItemOfPage, lastRenderedIndex -> indexOfLastItemOfPage to lastRenderedIndex }
90109

91110
finalFlow.first { (indexOfLastItemOfPage, lastRenderedIndex) ->
92-
lastRenderedIndex > indexOfLastItemOfPage - config.finalFetchDistance
111+
val result = lastRenderedIndex > indexOfLastItemOfPage - config.finalFetchDistance
112+
result
93113
}
94114
}
95115

0 commit comments

Comments
 (0)