commonMain.internal.ActionManager.kt Maven / Gradle / Ivy
package io.kform.internal
import io.kform.AbsolutePath
import io.kform.FormManager
import io.kform.collections.PathMultimapEntryId
import io.kform.collections.mutablePathMultimapOf
import kotlin.time.TimeMark
import kotlin.time.TimeSource
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/** Type of access: read or write. Read accesses can always run concurrently. */
internal enum class ActionAccessType {
Read,
Write
}
/**
* Representation of an access a certain action makes. Accesses of type `Read` never conflict with
* one another. Accesses of type `Write` may conflict with other accesses based on [conflictsWith].
*/
internal interface ActionAccess {
/** Type of access (`Read` or `Write`). */
val accessType: ActionAccessType
/**
* Returns whether this access conflicts with the provided [access]. Either this or the provided
* access will be of type `Write` when this method is called, so no need to confirm that.
*/
fun conflictsWith(access: ActionAccess): Boolean
}
/**
* Representation of an action to be performed (emitted by the form manager) with return type [T].
*/
internal interface Action {
/**
* Accesses that this action makes. The action manager uses these accesses together with
* [accessedPaths] to know which actions conflict.
*/
val accesses: Iterable
/**
* Paths representing the resources that this action accesses. Other actions will possibly
* conflict with this action when the intersection of accessed paths between both actions isn't
* empty.
*/
// TODO: Consider moving accessed paths to within accesses (might complicate the algorithm)
val accessedPaths: Iterable
/**
* Priority of the action (`>= 0`). A smaller number means greater priority and `0` (the
* default) represents maximum priority.
*
* If this action conflicts with another action with a lower priority, then that other action
* will be cancelled by the action manager.
*/
val priority: Int
get() = 0
/** Result of the action. */
val result: CompletableDeferred
/**
* Returns whether this action overrides the provided [action]. If so, the action manager will
* cancel that action in favor of this one.
*/
fun overridesConflictingAction(action: Action<*>): Boolean = false
/** Function called by the action manager that runs the action and returns its result. */
suspend fun run(): T
/** Awaits for this action to finish running and awaits its result. */
suspend fun await(): T = result.await()
/** Cancels the action. */
fun cancel(cause: CancellationException? = null): Unit = result.cancel(cause)
}
/**
* Job representing a scheduled or running action. [waitingFor] denotes the number of jobs this
* action is waiting to complete before it can run. [waitedBy] contains
*/
private class ActionJob(val action: Action) {
/** Job running the action. When `null`, the action is not yet running. */
var job: Job? = null
/** Set of jobs this job is waiting for before it can run. */
val waitingFor = mutableSetOf>()
/** Set of jobs that are waiting for this job to finish before being allowed to run. */
val waitedBy = mutableSetOf>()
/**
* Identifiers of the paths we have "locked" to be able to run. After the completion of this job
* we use them to "unlock" the paths.
*/
val lockedPathsIds = mutableListOf()
}
/**
* Manager of actions emitted by the form manager: it is responsible for launching actions in a safe
* manner, possibly concurrently.
*/
internal class ActionManager(private val scope: CoroutineScope) {
private val actionJobsMutex = Mutex()
/**
* Action jobs currently being processed. Each job is associated with one or more paths,
* representing the resources that each job has "locked".
*/
private val actionJobs = mutablePathMultimapOf>()
/**
* Schedules the provided [action] to run and returns a [Job] that completes once the action has
* been actually scheduled.
*/
suspend fun scheduleAction(action: Action) =
actionJobsMutex.withLock {
val actionJob = ActionJob(action)
// Check which actions conflict with this action and wait for them to finish
val conflicts = conflicts(action)
FormManager.logger.trace {
val waitingFor =
conflicts.toList().joinToString { job ->
"${job.action} (${job.waitingFor.size})"
}
"Scheduling action: $action (waiting for: [$waitingFor])"
}
for (conflict in conflicts) {
actionJob.waitingFor += conflict
conflict.waitedBy += actionJob
}
// Add action's accessed paths to the action jobs structure, basically stating that they
// are "in use" by this action and thus preventing conflicting actions from running
// concurrently
for (accessedPath in action.accessedPaths) {
actionJob.lockedPathsIds += actionJobs.put(accessedPath, actionJob)
}
// Launch when the action is waiting for no jobs to complete
if (actionJob.waitingFor.isEmpty()) {
launchActionJob(actionJob)
}
}
/**
* Returns the set of action jobs that conflict with [action], cancelling conflicting actions
* when appropriate.
*/
private fun conflicts(action: Action<*>): Set> {
val conflicts = mutableSetOf>()
for (accessedPath in action.accessedPaths) {
// Sort the jobs topologically in order to cancel as many jobs as possible. If not
// topologically sorted, we may end up not cancelling a job that this action overrides
// because it is being waited by another job that this action has priority over. By
// topologically sorting the jobs first, we make sure that in such a scenario, we would
// first cancel the job that this action has priority over and when we reach the job
// that this action overrides it no longer has any jobs waiting for it, so we can also
// cancel it.
for (conflictingActionJob in sortActionJobsTopologically(actionJobs[accessedPath])) {
val conflictingAction = conflictingActionJob.action
if (!actionsConflict(action, conflictingAction)) {
continue
}
// Whether to cancel the conflicting action and why
var shouldCancel = false
var cancelCause: CancellationException? = null
if (action.priority < conflictingAction.priority) {
shouldCancel = true
} else if (
action.overridesConflictingAction(conflictingAction) &&
conflictingActionJob.waitedBy.isEmpty()
) {
shouldCancel = true
cancelCause = OverriddenActionException(action)
}
// If we cancel a job that hasn't started running yet, it is immediately cleaned up
// and, as such, there is no need to depend on it
var conflictHasBeenCleanedUp = false
if (shouldCancel) {
conflictHasBeenCleanedUp = cancelActionJob(conflictingActionJob, cancelCause)
}
if (!conflictHasBeenCleanedUp) {
conflicts += conflictingActionJob
}
}
}
return conflicts
}
/** Whether two actions conflict with each other based on their accesses. */
private fun actionsConflict(action1: Action<*>, action2: Action<*>) =
action1.accesses.any { access1 ->
action2.accesses.any { access2 ->
(access1.accessType == ActionAccessType.Write ||
access2.accessType == ActionAccessType.Write) && access1.conflictsWith(access2)
}
}
/** Launches the action job [actionJob]. */
private fun launchActionJob(actionJob: ActionJob) {
val action = actionJob.action
val job =
scope.launch(CoroutineName("Action job")) {
// Measure the time it took to run the action when logging in debug level
val mark =
if (FormManager.logger.isDebugEnabled()) TimeSource.Monotonic.markNow()
else null
try {
FormManager.logger.debug { "Action started: $action" }
action.result.complete(action.run())
FormManager.logger.debug {
"Action completed successfully${timeTag(mark)}: $action"
}
} catch (ex: Throwable) {
if (ex is CancellationException) {
FormManager.logger.debug {
"Action completed by cancellation${timeTag(mark)}: $action ($ex)"
}
} else {
FormManager.logger.error(ex) {
"Action completed exceptionally${timeTag(mark)}: $action"
}
}
action.result.completeExceptionally(ex)
}
}
actionJob.job = job
// If the action was cancelled (by cancelling its result), then we cancel the job
action.result.invokeOnCompletion { ex ->
if (ex is CancellationException) {
job.cancel(ex)
}
}
// Cleanup the action job
job.invokeOnCompletion {
scope.launch { actionJobsMutex.withLock { cleanUpActionJob(actionJob) } }
}
}
/**
* Cancels the [action job][actionJob] and returns whether it was immediately cleaned up (i.e.
* if the job was cancelled before it started running).
*
* @param cause Cause of the cancellation.
*/
private fun cancelActionJob(
actionJob: ActionJob,
cause: CancellationException? = null
): Boolean {
val action = actionJob.action
val job = actionJob.job
if (job != null) {
if (job.isActive) {
FormManager.logger.debug { "Action cancelled after launching: $action" }
job.cancel(cause)
}
// Cancel the `result` here since the job may be cancelled before it even starts and, as
// such, `result` would never complete. It's fine for the job to try to complete an
// already completed deferred.
action.result.cancel(cause)
} else {
if (action.result.isActive) {
FormManager.logger.debug { "Action cancelled before launching: $action" }
action.result.cancel(cause)
}
for (waitedByJob in actionJob.waitingFor) {
waitedByJob.waitedBy -= actionJob
}
cleanUpActionJob(actionJob)
}
return job == null
}
/**
* Cleans up [actionJob] by removing it from [actionJobs] (thus "unlocking" the paths in use by
* this action) and launching jobs that were waiting for it when appropriate.
*/
private fun cleanUpActionJob(actionJob: ActionJob<*>) {
FormManager.logger.trace {
val actionsWaiting =
actionJob.waitedBy.joinToString { job -> "${job.action} (${job.waitingFor.size})" }
"Cleaning up action: ${actionJob.action} (waited by: [$actionsWaiting])"
}
for (id in actionJob.lockedPathsIds) {
actionJobs.removeEntry(id)
}
for (waitingJob in actionJob.waitedBy) {
waitingJob.waitingFor -= actionJob
if (waitingJob.waitingFor.isEmpty()) {
launchActionJob(waitingJob)
}
}
}
/**
* Sorts the provided sequence of [actionJobs] topologically so that if one job in the sequence
* depends on another, the job being depended on will appear after the depending job in the
* resulting list.
*/
private fun sortActionJobsTopologically(
actionJobs: Sequence>
): List> {
val actionJobsSet = actionJobs.toSet()
val visited = HashSet>(actionJobsSet.size)
val deque = ArrayDeque>(actionJobsSet.size)
// Depth first search where nodes are prepended to the deque
fun dfs(actionJob: ActionJob<*>) {
visited += actionJob
for (waitedByJob in actionJob.waitingFor) {
if (waitedByJob in actionJobsSet && waitedByJob !in visited) {
dfs(waitedByJob)
}
}
deque.addFirst(actionJob)
}
for (actionJob in actionJobsSet) {
if (actionJob !in visited) {
dfs(actionJob)
}
}
return deque
}
private fun timeTag(mark: TimeMark?): String =
mark?.elapsedNow()?.inWholeMilliseconds?.let { " [${it}ms]" } ?: ""
}