Skip to content

Commit a5906f9

Browse files
GUWT step 1: Implement runningSideEffect API
First part of Kotlin implementation of square/workflow#1021. Kotlin counterpart to square/workflow#1174. This implementation intentionally does not run the side effect coroutine on the `workerContext` `CoroutineContext` that is threaded through the runtime for testing infrastructure. Initially, workers ran in the same context as the workflow runtime. The behavior of running workers on a different dispatcher by default (`Unconfined`) was introduced in square/workflow#851 as an optimization to reduce the overhead for running workers that only perform wiring tasks with other async libraries. This was a theoretical optimization, since running on the `Unconfined` dispatcher inherently involves less dispatching work, but the overhead of dispatching wiring coroutines was never actually shown to be a problem. Additionally, because tests often need to be in full control of dispatchers, the ability to override the context used for workers was introduced in square/workflow#940, which introduced `workerContext`. I am dropping that complexity here because it adds a decent amount of complexity to worker/side effect machinery without any proven value. It is also complexity that needs to be documented, and is probably just more confusing than anything. The old behavior for workers is maintained for now to reduce the risk of this change, but side effects will always run in the workflow runtime's context. This is nice and simple and unsurprising and easy to reason about.
1 parent 4bc78b7 commit a5906f9

File tree

12 files changed

+596
-51
lines changed

12 files changed

+596
-51
lines changed

workflow-core/api/workflow-core.api

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public abstract interface class com/squareup/workflow/RenderContext {
2727
public abstract fun makeActionSink ()Lcom/squareup/workflow/Sink;
2828
public abstract fun onEvent (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function1;
2929
public abstract fun renderChild (Lcom/squareup/workflow/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object;
30+
public abstract fun runningSideEffect (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
3031
public abstract fun runningWorker (Lcom/squareup/workflow/Worker;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
3132
}
3233

workflow-core/src/main/java/com/squareup/workflow/RenderContext.kt

+25
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,31 @@ interface RenderContext<StateT, in OutputT : Any> {
116116
key: String = "",
117117
handler: (T) -> WorkflowAction<StateT, OutputT>
118118
)
119+
120+
/**
121+
* Ensures [sideEffect] is running with the given [key].
122+
*
123+
* The first render pass in which this method is called, [sideEffect] will be launched in a new
124+
* coroutine that will be scoped to the rendering [Workflow]. Subsequent render passes that invoke
125+
* this method with the same [key] will not launch the coroutine again, but let it keep running.
126+
* Note that if a different function is passed with the same key, the side effect will *not* be
127+
* restarted, the new function will simply be ignored. The next render pass in which the
128+
* workflow does not call this method with the same key, the coroutine running [sideEffect] will
129+
* be
130+
* [cancelled](https://kotlinlang.org/docs/reference/coroutines/cancellation-and-timeouts.html).
131+
*
132+
* The coroutine will run with the same [CoroutineContext][kotlin.coroutines.CoroutineContext]
133+
* that the workflow runtime is running in. The side effect coroutine will not be started until
134+
* _after_ the first render call than runs it returns.
135+
*
136+
* @param key The string key that is used to distinguish between side effects.
137+
* @param sideEffect The suspend function that will be launched in a coroutine to perform the
138+
* side effect.
139+
*/
140+
fun runningSideEffect(
141+
key: String,
142+
sideEffect: suspend () -> Unit
143+
)
119144
}
120145

121146
/**

workflow-runtime/api/workflow-runtime.api

+6-1
Original file line numberDiff line numberDiff line change
@@ -235,13 +235,14 @@ public final class com/squareup/workflow/diagnostic/WorkflowUpdateDebugInfo$Sour
235235
}
236236

237237
public final class com/squareup/workflow/internal/RealRenderContext : com/squareup/workflow/RenderContext, com/squareup/workflow/Sink {
238-
public fun <init> (Lcom/squareup/workflow/internal/RealRenderContext$Renderer;Lcom/squareup/workflow/internal/RealRenderContext$WorkerRunner;Lkotlinx/coroutines/channels/SendChannel;)V
238+
public fun <init> (Lcom/squareup/workflow/internal/RealRenderContext$Renderer;Lcom/squareup/workflow/internal/RealRenderContext$WorkerRunner;Lcom/squareup/workflow/internal/RealRenderContext$SideEffectRunner;Lkotlinx/coroutines/channels/SendChannel;)V
239239
public final fun freeze ()V
240240
public fun getActionSink ()Lcom/squareup/workflow/Sink;
241241
public fun makeActionSink ()Lcom/squareup/workflow/Sink;
242242
public fun onEvent (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow/EventHandler;
243243
public synthetic fun onEvent (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function1;
244244
public fun renderChild (Lcom/squareup/workflow/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object;
245+
public fun runningSideEffect (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
245246
public fun runningWorker (Lcom/squareup/workflow/Worker;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
246247
public fun send (Lcom/squareup/workflow/WorkflowAction;)V
247248
public synthetic fun send (Ljava/lang/Object;)V
@@ -251,6 +252,10 @@ public abstract interface class com/squareup/workflow/internal/RealRenderContext
251252
public abstract fun render (Lcom/squareup/workflow/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object;
252253
}
253254

255+
public abstract interface class com/squareup/workflow/internal/RealRenderContext$SideEffectRunner {
256+
public abstract fun runningSideEffect (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
257+
}
258+
254259
public abstract interface class com/squareup/workflow/internal/RealRenderContext$WorkerRunner {
255260
public abstract fun runningWorker (Lcom/squareup/workflow/Worker;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
256261
}

workflow-runtime/src/main/java/com/squareup/workflow/internal/RealRenderContext.kt

+16
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import kotlinx.coroutines.channels.SendChannel
3333
class RealRenderContext<StateT, OutputT : Any>(
3434
private val renderer: Renderer<StateT, OutputT>,
3535
private val workerRunner: WorkerRunner<StateT, OutputT>,
36+
private val sideEffectRunner: SideEffectRunner,
3637
private val eventActionsChannel: SendChannel<WorkflowAction<StateT, OutputT>>
3738
) : RenderContext<StateT, OutputT>, Sink<WorkflowAction<StateT, OutputT>> {
3839

@@ -53,6 +54,13 @@ class RealRenderContext<StateT, OutputT : Any>(
5354
)
5455
}
5556

57+
interface SideEffectRunner {
58+
fun runningSideEffect(
59+
key: String,
60+
sideEffect: suspend () -> Unit
61+
)
62+
}
63+
5664
/**
5765
* False during the current render call, set to true once this node is finished rendering.
5866
*
@@ -104,6 +112,14 @@ class RealRenderContext<StateT, OutputT : Any>(
104112
workerRunner.runningWorker(worker, key, handler)
105113
}
106114

115+
override fun runningSideEffect(
116+
key: String,
117+
sideEffect: suspend () -> Unit
118+
) {
119+
checkNotFrozen()
120+
sideEffectRunner.runningSideEffect(key, sideEffect)
121+
}
122+
107123
/**
108124
* Freezes this context so that any further calls to this context will throw.
109125
*/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright 2020 Square Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.squareup.workflow.internal
17+
18+
import com.squareup.workflow.internal.InlineLinkedList.InlineListNode
19+
import kotlinx.coroutines.Job
20+
21+
/**
22+
* Holds a [Job] that represents a running [side effect][RealRenderContext.runningSideEffect], as
23+
* well as the key used to identify that side effect.
24+
*/
25+
internal class SideEffectNode(
26+
val key: String,
27+
val job: Job
28+
) : InlineListNode<SideEffectNode> {
29+
30+
override var nextListNode: SideEffectNode? = null
31+
}

workflow-runtime/src/main/java/com/squareup/workflow/internal/WorkflowNode.kt

+38-3
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,28 @@
1515
*/
1616
package com.squareup.workflow.internal
1717

18+
import com.squareup.workflow.ExperimentalWorkflow
1819
import com.squareup.workflow.Snapshot
1920
import com.squareup.workflow.StatefulWorkflow
20-
import com.squareup.workflow.ExperimentalWorkflow
2121
import com.squareup.workflow.Worker
2222
import com.squareup.workflow.Workflow
2323
import com.squareup.workflow.WorkflowAction
2424
import com.squareup.workflow.applyTo
2525
import com.squareup.workflow.diagnostic.IdCounter
2626
import com.squareup.workflow.diagnostic.WorkflowDiagnosticListener
2727
import com.squareup.workflow.diagnostic.createId
28+
import com.squareup.workflow.internal.RealRenderContext.SideEffectRunner
2829
import com.squareup.workflow.internal.RealRenderContext.WorkerRunner
2930
import kotlinx.coroutines.CancellationException
3031
import kotlinx.coroutines.CoroutineName
3132
import kotlinx.coroutines.CoroutineScope
33+
import kotlinx.coroutines.CoroutineStart.LAZY
3234
import kotlinx.coroutines.Job
3335
import kotlinx.coroutines.cancel
3436
import kotlinx.coroutines.channels.Channel
3537
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
38+
import kotlinx.coroutines.launch
39+
import kotlinx.coroutines.plus
3640
import kotlinx.coroutines.selects.SelectBuilder
3741
import okio.ByteString
3842
import kotlin.coroutines.CoroutineContext
@@ -63,7 +67,7 @@ internal class WorkflowNode<PropsT, StateT, OutputT : Any, RenderingT>(
6367
private val idCounter: IdCounter? = null,
6468
initialState: StateT? = null,
6569
private val workerContext: CoroutineContext = EmptyCoroutineContext
66-
) : CoroutineScope, WorkerRunner<StateT, OutputT> {
70+
) : CoroutineScope, WorkerRunner<StateT, OutputT>, SideEffectRunner {
6771

6872
/**
6973
* Context that has a job that will live as long as this node.
@@ -84,6 +88,8 @@ internal class WorkflowNode<PropsT, StateT, OutputT : Any, RenderingT>(
8488

8589
private val workers = ActiveStagingList<WorkerChildNode<*, *, *>>()
8690

91+
private val sideEffects = ActiveStagingList<SideEffectNode>()
92+
8793
private var state: StateT
8894

8995
private var lastProps: PropsT = initialProps
@@ -144,7 +150,7 @@ internal class WorkflowNode<PropsT, StateT, OutputT : Any, RenderingT>(
144150
key: String,
145151
handler: (T) -> WorkflowAction<StateT, OutputT>
146152
) {
147-
// Prevent duplicate workflows with the same key.
153+
// Prevent duplicate workers with the same key.
148154
workers.forEachStaging {
149155
require(!(it.matches(worker, key))) {
150156
"Expected keys to be unique for $worker: key=$key"
@@ -159,6 +165,21 @@ internal class WorkflowNode<PropsT, StateT, OutputT : Any, RenderingT>(
159165
stagedWorker.setHandler(handler)
160166
}
161167

168+
override fun runningSideEffect(
169+
key: String,
170+
sideEffect: suspend () -> Unit
171+
) {
172+
// Prevent duplicate side effects with the same key.
173+
sideEffects.forEachStaging {
174+
require(key != it.key) { "Expected side effect keys to be unique: $key" }
175+
}
176+
177+
sideEffects.retainOrCreate(
178+
predicate = { key == it.key },
179+
create = { createSideEffectNode(key, sideEffect) }
180+
)
181+
}
182+
162183
/**
163184
* Gets the next [output][OutputT] from the state machine.
164185
*
@@ -229,6 +250,7 @@ internal class WorkflowNode<PropsT, StateT, OutputT : Any, RenderingT>(
229250
val context = RealRenderContext(
230251
renderer = subtreeManager,
231252
workerRunner = this,
253+
sideEffectRunner = this,
232254
eventActionsChannel = eventActionsChannel
233255
)
234256
diagnosticListener?.onBeforeWorkflowRendered(diagnosticId, props, state)
@@ -239,6 +261,10 @@ internal class WorkflowNode<PropsT, StateT, OutputT : Any, RenderingT>(
239261
// Tear down workflows and workers that are obsolete.
240262
subtreeManager.commitRenderedChildren()
241263
workers.commitStaging { it.channel.cancel() }
264+
// Side effect jobs are launched lazily, since they can send actions to the sink, and can only
265+
// be started after context is frozen.
266+
sideEffects.forEachStaging { it.job.start() }
267+
sideEffects.commitStaging { it.job.cancel() }
242268

243269
return rendering
244270
}
@@ -282,6 +308,15 @@ internal class WorkflowNode<PropsT, StateT, OutputT : Any, RenderingT>(
282308
return WorkerChildNode(worker, key, workerChannel, handler = handler)
283309
}
284310

311+
private fun createSideEffectNode(
312+
key: String,
313+
sideEffect: suspend () -> Unit
314+
): SideEffectNode {
315+
val scope = this + CoroutineName("sideEffect[$key] for $id")
316+
val job = scope.launch(start = LAZY) { sideEffect() }
317+
return SideEffectNode(key, job)
318+
}
319+
285320
private fun ByteString.restoreState(): Snapshot? {
286321
val (snapshotToRestoreFrom, childSnapshots) = parseTreeSnapshot(this)
287322
subtreeManager.restoreChildrenFromSnapshots(childSnapshots)

0 commit comments

Comments
 (0)