Skip to content

Commit

Permalink
Make formula internals thread safe.
Browse files Browse the repository at this point in the history
  • Loading branch information
Laimiux committed Jan 22, 2024
1 parent c7ed4d2 commit 1e93038
Show file tree
Hide file tree
Showing 14 changed files with 279 additions and 79 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package com.instacart.formula.coroutines
import com.instacart.formula.FormulaRuntime
import com.instacart.formula.IFormula
import com.instacart.formula.Inspector
import com.instacart.formula.internal.ThreadChecker
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
Expand All @@ -22,20 +21,16 @@ object FlowRuntime {
inspector: Inspector? = null,
isValidationEnabled: Boolean = false,
): Flow<Output> {
val threadChecker = ThreadChecker(formula)
return callbackFlow<Output> {
threadChecker.check("Need to subscribe on main thread.")

val runtime = FormulaRuntime(
threadChecker = threadChecker,
formula = formula,
onOutput = this::trySendBlocking,
onError = this::close,
inspector = inspector,
isValidationEnabled = isValidationEnabled,
)

input.onEach { input -> runtime.onInput(input) }.launchIn(this)
input.onEach(runtime::onInput).launchIn(this)

awaitClose {
runtime.terminate()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package com.instacart.formula.rxjava3

import com.instacart.formula.FormulaPlugins
import com.instacart.formula.FormulaRuntime
import com.instacart.formula.IFormula
import com.instacart.formula.Inspector
import com.instacart.formula.internal.ThreadChecker
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.FormulaDisposableHelper
Expand All @@ -16,12 +14,8 @@ object RxJavaRuntime {
inspector: Inspector? = null,
isValidationEnabled: Boolean = false,
): Observable<Output> {
val threadChecker = ThreadChecker(formula)
return Observable.create { emitter ->
threadChecker.check("Need to subscribe on main thread.")

return Observable.create<Output> { emitter ->
val runtime = FormulaRuntime(
threadChecker = threadChecker,
formula = formula,
onOutput = emitter::onNext,
onError = emitter::onError,
Expand All @@ -30,11 +24,9 @@ object RxJavaRuntime {
)

val disposables = CompositeDisposable()
disposables.add(input.subscribe({ input ->
runtime.onInput(input)
}, emitter::onError))

disposables.add(input.subscribe(runtime::onInput, emitter::onError))
disposables.add(FormulaDisposableHelper.fromRunnable(runtime::terminate))

emitter.setDisposable(disposables)
}.distinctUntilChanged()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class TestFormulaObserver<Input : Any, Output : Any, FormulaT : IFormula<Input,
private val delegate: FormulaTestDelegate<Input, Output, FormulaT>,
) {

private var started: Boolean = false
@Volatile private var started: Boolean = false

val formula: FormulaT = delegate.formula

Expand Down
33 changes: 23 additions & 10 deletions formula/src/main/java/com/instacart/formula/FormulaRuntime.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,71 +3,83 @@ package com.instacart.formula
import com.instacart.formula.internal.FormulaManager
import com.instacart.formula.internal.FormulaManagerImpl
import com.instacart.formula.internal.ManagerDelegate
import com.instacart.formula.internal.ThreadChecker
import com.instacart.formula.internal.SynchronizedEventQueue
import java.util.LinkedList

/**
* Takes a [Formula] and creates an Observable<Output> from it.
*/
class FormulaRuntime<Input : Any, Output : Any>(
private val threadChecker: ThreadChecker,
private val formula: IFormula<Input, Output>,
private val onOutput: (Output) -> Unit,
private val onError: (Throwable) -> Unit,
private val isValidationEnabled: Boolean = false,
inspector: Inspector? = null,
) : ManagerDelegate {
private val synchronizedEventQueue = SynchronizedEventQueue()
private val inspector = FormulaPlugins.inspector(type = formula.type(), local = inspector)
private val implementation = formula.implementation()

@Volatile
private var manager: FormulaManagerImpl<Input, *, Output>? = null
private val inspector = FormulaPlugins.inspector(
type = formula.type(),
local = inspector,
)

@Volatile
private var emitOutput = false

@Volatile
private var lastOutput: Output? = null

@Volatile
private var input: Input? = null

@Volatile
private var key: Any? = null

/**
* Determines if we are executing within [runFormula] block. It prevents to
* enter [runFormula] block when we are already within it.
*/
@Volatile
private var isRunning: Boolean = false

/**
* When we are within the [run] block, inputId allows us to notice when input has changed
* and to re-run when that happens.
*/
@Volatile
private var inputId: Int = 0

/**
* Global transition effect queue which executes side-effects
* after all formulas are idle.
*/
// TODO: not memory safe for multi-threading
private var globalEffectQueue = LinkedList<Effects>()

/**
* Determines if we are iterating through [globalEffectQueue]. It prevents us from
* entering executeTransitionEffects block when we are already within it.
*/
@Volatile
private var isExecutingEffects: Boolean = false

/**
* This is a global termination flag that indicates that upstream has disposed of the
* this [FormulaRuntime] instance. We will not accept any more [onInput] changes and will
* not emit any new [Output] events.
*/
@Volatile
private var isRuntimeTerminated: Boolean = false

private fun isKeyValid(input: Input): Boolean {
return this.input == null || key == formula.key(input)
}

fun onInput(input: Input) {
threadChecker.check("Input arrived on a wrong thread.")
synchronizedEventQueue.postUpdate { onInputInternal(input) }
}

private fun onInputInternal(input: Input) {
if (isRuntimeTerminated) return

val isKeyValid = isKeyValid(input)
Expand Down Expand Up @@ -105,8 +117,10 @@ class FormulaRuntime<Input : Any, Output : Any>(
}

fun terminate() {
threadChecker.check("Need to unsubscribe on the main thread.")
synchronizedEventQueue.postUpdate(this::terminateInternal)
}

private fun terminateInternal() {
if (isRuntimeTerminated) return
isRuntimeTerminated = true

Expand All @@ -127,8 +141,6 @@ class FormulaRuntime<Input : Any, Output : Any>(
}

override fun onPostTransition(effects: Effects?, evaluate: Boolean) {
threadChecker.check("Only thread that created it can post transition result")

effects?.let {
globalEffectQueue.addLast(effects)
}
Expand Down Expand Up @@ -271,6 +283,7 @@ class FormulaRuntime<Input : Any, Output : Any>(

private fun initManager(initialInput: Input): FormulaManagerImpl<Input, *, Output> {
return FormulaManagerImpl(
queue = synchronizedEventQueue,
delegate = this,
formula = implementation,
initialInput = initialInput,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal class ActionManager(
val NO_OP: (Any?) -> Unit = {}
}

// TODO: these need to be thread safe collections
private var running: LinkedHashSet<DeferredAction<*>>? = null
private var actions: Set<DeferredAction<*>>? = null

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ internal class ChildrenManager(
val childFormulaHolder = children.findOrInit(key) {
val implementation = formula.implementation()
FormulaManagerImpl(
queue = delegate.queue,
delegate = delegate,
formula = implementation,
initialInput = input,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import kotlin.reflect.KClass
* a state change, it will rerun [Formula.evaluate].
*/
internal class FormulaManagerImpl<Input, State, Output>(
val queue: SynchronizedEventQueue,
private val delegate: ManagerDelegate,
private val formula: Formula<Input, State, Output>,
initialInput: Input,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ internal class ListenerImpl<Input, State, EventT>(internal var key: Any) : Liste
// TODO: log if null listener (it might be due to formula removal or due to callback removal)
val manager = manager ?: return

val deferredTransition = DeferredTransition(this, transition, event)
manager.onPendingTransition(deferredTransition)
manager.queue.postUpdate {
val deferredTransition = DeferredTransition(this, transition, event)
manager.onPendingTransition(deferredTransition)
}
}

fun disable() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.instacart.formula.internal

import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.atomic.AtomicReference

/**
* A non-blocking event queue that processes formula updates.
*/
class SynchronizedEventQueue {
private val threadRunning = AtomicReference<Thread>()
private val concurrentLinkedQueue = ConcurrentLinkedQueue<() -> Unit>()

/**
* All top-level formula interactions that trigger formula side-effects are posted here
* to make sure that they are executed one at a time. If there is a thread currently running
* formula, we hand the update to that thread for processing. The following
* root formula events are propagated via this queue:
* - Input change
* - Individual formula transitions
* - Termination
*
* Implementation works by having a concurrent queue and checking:
* - If queue is idle, execute current update and try to process other queue entries
* - If queue is running by the same thread, we execute current update and let other
* updates be handled by existing processing loop.
* - If queue is running by a different thread, add to the queue and see if we need to
* take over the processing.
*/
fun postUpdate(runnable: () -> Unit) {
val currentThread = Thread.currentThread()
val owner = threadRunning.get()
if (owner == currentThread) {
// Since we are on the same thread, just execute the event (no need to grab ownership)
runnable()
} else if (owner == null) {
if (threadRunning.compareAndSet(null, currentThread)) {
// The queue is idle, we first execute our own event and then move to the queue
runnable()
threadRunning.set(null)

tryToProcessQueueIfNeeded(currentThread)
} else {
concurrentLinkedQueue.add(runnable)
tryToProcessQueueIfNeeded(currentThread)
}
} else {
concurrentLinkedQueue.add(runnable)
tryToProcessQueueIfNeeded(currentThread)
}
}

private fun tryToProcessQueueIfNeeded(currentThread: Thread) {
while (true) {
// First, we peek to see if there is a value to process.
val peekUpdate = concurrentLinkedQueue.peek()
if (peekUpdate != null) {
// If there is a value to process, we check if we should process it.
if (threadRunning.compareAndSet(null, currentThread)) {
// We successfully set ourselves as the running thread
// We poll the queue to get the latest value (it could have changed). It
// also removes the value from the queue.
val actualUpdate = concurrentLinkedQueue.poll()
actualUpdate?.invoke()
threadRunning.set(null)
} else {
// Some other thread is running, let that thread execute the update.
return
}
} else {
return
}
}
}
}

This file was deleted.

Loading

0 comments on commit 1e93038

Please sign in to comment.