All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.internal.ActionManager.kt Maven / Gradle / Ivy

There is a newer version: 0.23.0
Show newest version
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]" } ?: ""
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy