-
Notifications
You must be signed in to change notification settings - Fork 3
Improve Component testability + add example tests #125
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
base: develop
Are you sure you want to change the base?
Changes from all commits
972dc04
52d46c8
5b2377c
7ca0b5b
16fea98
a96c990
831379f
0ed808f
e7edbef
4e5d6dd
a5a68db
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 |
|---|---|---|
| @@ -1,22 +1,16 @@ | ||
| package app.futured.arkitekt.crusecases | ||
|
|
||
| import app.futured.arkitekt.crusecases.scope.FlowUseCaseExecutionScope | ||
| import kotlinx.coroutines.Job | ||
| import kotlinx.coroutines.flow.Flow | ||
|
|
||
| /** | ||
| * Base [Flow] use case meant to use in [FlowUseCaseExecutionScope] implementations | ||
| */ | ||
| abstract class FlowUseCase<ARGS, T> { | ||
|
|
||
| /** | ||
| * [Job] used to hold and cancel existing run of this use case | ||
| */ | ||
| var job: Job? = null | ||
| interface FlowUseCase<ARGS, T> { | ||
|
|
||
| /** | ||
| * Function which builds Flow instance based on given arguments | ||
| * @param args initial use case arguments | ||
| */ | ||
| abstract fun build(args: ARGS): Flow<T> | ||
| fun build(args: ARGS): Flow<T> | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,20 +1,14 @@ | ||
| package app.futured.arkitekt.crusecases | ||
|
|
||
| import app.futured.arkitekt.crusecases.scope.SingleUseCaseExecutionScope | ||
| import kotlinx.coroutines.Deferred | ||
|
|
||
| /** | ||
| * Base Coroutine use case meant to use in [SingleUseCaseExecutionScope] implementations | ||
| */ | ||
| abstract class UseCase<ARGS, T> { | ||
|
|
||
| /** | ||
| * [Deferred] used to hold and cancel existing run of this use case | ||
| */ | ||
| var deferred: Deferred<T>? = null | ||
| interface UseCase<ARGS, T> { | ||
|
|
||
| /** | ||
| * Suspend function which should contain business logic | ||
| */ | ||
| abstract suspend fun build(args: ARGS): T | ||
| suspend fun build(args: ARGS): T | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ package app.futured.arkitekt.crusecases.scope | |
|
|
||
| import app.futured.arkitekt.crusecases.FlowUseCase | ||
| import app.futured.arkitekt.crusecases.error.UseCaseErrorHandler | ||
| import kotlinx.coroutines.Job | ||
| import kotlinx.coroutines.flow.catch | ||
| import kotlinx.coroutines.flow.flowOn | ||
| import kotlinx.coroutines.flow.launchIn | ||
|
|
@@ -13,6 +14,11 @@ import kotlin.coroutines.cancellation.CancellationException | |
|
|
||
| interface FlowUseCaseExecutionScope : CoroutineScopeOwner { | ||
|
|
||
| /** | ||
| * Map of [Job] objects used to hold and cancel existing run of any [FlowUseCase] instance. | ||
| */ | ||
| val useCaseJobPool: MutableMap<Any, Job> | ||
|
|
||
|
Comment on lines
+17
to
+21
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. 🛠️ Refactor suggestion Potential memory leak & thread-safety in Jobs are stored but never removed; finished or cancelled jobs will accumulate in the map for the lifetime of the scope. @@
- val useCaseJobPool: MutableMap<Any, Job>
+ val useCaseJobPool: MutableMap<Any, Job> // consider ConcurrentHashMap on JVM / atomic wrapper on native🤖 Prompt for AI Agents
Member
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. Can you elaborate with concrete examples? 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. @matejsemancik, let me provide concrete examples of both issues: Memory Leak ExampleConsider a screen component that executes a data refresh use case multiple times: class DataScreenComponent : BaseComponent<DataViewState, DataEvent>() {
private val refreshDataUseCase = RefreshDataUseCase()
fun onRefreshClicked() {
// This gets called every time user pulls to refresh
refreshDataUseCase.execute {
onNext { data -> updateState { copy(data = data) } }
}
}
}What happens:
The cancelled jobs can't be garbage collected because the map holds references to them. Thread Safety ExampleConsider concurrent execution from different coroutine contexts: class ConcurrentComponent : BaseComponent<ViewState, Event>() {
private val useCase1 = NetworkUseCase()
private val useCase2 = DatabaseUseCase()
fun loadData() {
// Both launched simultaneously on different dispatchers
launch(Dispatchers.IO) {
useCase1.execute { /* ... */ } // Thread A
}
launch(Dispatchers.Main) {
useCase2.execute { /* ... */ } // Thread B
}
}
}Race condition scenario: // Thread A executing useCase1.execute():
if (flowUseCaseConfig.disposePrevious) {
useCaseJobPool[this]?.cancel() // Read operation
// Context switch happens here!
}
useCaseJobPool[this] = newJob // Write operation
// Thread B executing useCase2.execute() at the same time:
if (flowUseCaseConfig.disposePrevious) {
useCaseJobPool[this]?.cancel() // Read operation
}
useCaseJobPool[this] = anotherJob // Write operationThis can lead to:
Real-world ImpactMemory leak in long-running app: // A chat screen that refreshes messages every 30 seconds
class ChatComponent {
fun startPeriodicRefresh() {
repeat(Int.MAX_VALUE) {
delay(30_000)
messageUseCase.execute { /* update messages */ }
// After 24 hours: ~2,880 cancelled jobs in memory
}
}
}Solutions:
useCaseJobPool[this] = build(args)
.onCompletion {
useCaseJobPool.remove(this@execute) // Clean up when done
}
.launchIn(useCaseScope)
// JVM
val useCaseJobPool: ConcurrentHashMap<Any, Job> = ConcurrentHashMap()
// Multiplatform alternative
val useCaseJobPool: MutableMap<Any, Job> = Collections.synchronizedMap(mutableMapOf())
Member
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. @coderabbitai Bro, it's a Map. How does map keep reference to old canceled jobs, when each execution of UseCase cancels and overwrites the job in the map? 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. @matejsemancik You're absolutely right, and I apologize for the confusion! 🤦♂️ You've correctly identified the flaw in my memory leak analysis. Let me trace through what actually happens:
Once Job1's reference is overwritten, it becomes eligible for garbage collection. The map doesn't accumulate old jobs - it replaces them. Each UseCase instance can only hold one Job reference at a time. My memory leak concern was incorrect. The thread safety concern could still be relevant, but only in the specific scenario where the same UseCase instance is executed concurrently from different threads: // Thread A and Thread B both calling useCase.execute() simultaneously
// Thread A: useCaseJobPool[useCase]?.cancel()
// Thread B: useCaseJobPool[useCase]?.cancel() (might cancel Thread A's new job)
// Thread A: useCaseJobPool[useCase] = jobA
// Thread B: useCaseJobPool[useCase] = jobB (overwrites jobA)But in practice, this scenario is probably rare since components typically execute use cases in a coordinated manner. Thanks for catching that - the map behavior is much cleaner than I initially analyzed! 🐰 ✏️ Learnings added
|
||
| fun <T : Any?> FlowUseCase<Unit, T>.execute(config: FlowUseCaseConfig.Builder<T, T>.() -> Unit) = | ||
| execute(Unit, config) | ||
|
|
||
|
|
@@ -42,10 +48,10 @@ interface FlowUseCaseExecutionScope : CoroutineScopeOwner { | |
| } | ||
|
|
||
| if (flowUseCaseConfig.disposePrevious) { | ||
| job?.cancel() | ||
| useCaseJobPool[this]?.cancel() | ||
| } | ||
|
|
||
| job = build(args) | ||
| useCaseJobPool[this] = build(args) | ||
| .flowOn(getWorkerDispatcher()) | ||
| .onStart { flowUseCaseConfig.onStart() } | ||
matejsemancik marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| .mapNotNull { flowUseCaseConfig.onMap?.invoke(it) } | ||
|
|
@@ -65,7 +71,7 @@ interface FlowUseCaseExecutionScope : CoroutineScopeOwner { | |
| } | ||
| } | ||
| .catch { /* handled in onCompletion */ } | ||
| .launchIn(viewModelScope) | ||
| .launchIn(useCaseScope) | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -91,10 +97,10 @@ interface FlowUseCaseExecutionScope : CoroutineScopeOwner { | |
| } | ||
|
|
||
| if (flowUseCaseConfig.disposePrevious) { | ||
| job?.cancel() | ||
| useCaseJobPool[this]?.cancel() | ||
| } | ||
|
|
||
| job = build(args) | ||
| useCaseJobPool[this] = build(args) | ||
| .flowOn(getWorkerDispatcher()) | ||
| .onStart { flowUseCaseConfig.onStart() } | ||
| .onEach { flowUseCaseConfig.onNext(it) } | ||
|
|
@@ -113,7 +119,7 @@ interface FlowUseCaseExecutionScope : CoroutineScopeOwner { | |
| } | ||
| } | ||
| .catch { /* handled in onCompletion */ } | ||
| .launchIn(viewModelScope) | ||
| .launchIn(useCaseScope) | ||
| } | ||
|
|
||
| /** | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.