commonMain.internal.ValidationDaemon.kt Maven / Gradle / Ivy
package io.kform.internal
import io.kform.*
import io.kform.collections.mutablePathMultimapOf
import io.kform.collections.set
import io.kform.internal.actions.ValidateAction
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect
/** Messages used by the validation daemon. */
private sealed class ValidationDaemonMsg {
data object ValidateNext : ValidationDaemonMsg()
data object RetryValidate : ValidationDaemonMsg()
data class HandleInitEvent(val event: ValueEvent.Init<*>) : ValidationDaemonMsg()
data class HandleDestroyEvent(val event: ValueEvent.Destroy<*>) : ValidationDaemonMsg()
data class HandleValidationChangeEvent(val event: StateEvent.ValidationChange<*>) :
ValidationDaemonMsg()
}
/** Maximum number of paths of a certain schema allowed in "unvalidated paths". */
private const val PATH_LIMIT_PER_SCHEMA = 64
/** Daemon responsible for validating the form manager in the background. */
internal class ValidationDaemon(private val formManager: FormManager) {
// Validation daemon status
private val _status = MutableStateFlow(AutoValidationStatus.Inactive)
val status: StateFlow = _status
// Assignment to these variables is synchronised by the action manager (start/stop)
private var channel: Channel? = null
private var messagesHandlerJob: Job? = null
private var unsubscribeEventsHandler: Unsubscribe? = null
// Access to these variables is synchronised by [channel] and [messagesHandlerJob]
private var validateAction: Action? = null
private val unvalidatedPaths = mutablePathMultimapOf()
private var validating = false
private var validatingPath: AbsolutePath? = null
/** Starts the validation daemon. */
suspend fun start() {
if (status.value != AutoValidationStatus.Inactive) {
return
}
val newChannel = Channel(Channel.BUFFERED)
channel = newChannel
messagesHandlerJob =
formManager.scope.launch(CoroutineName("Validation daemon messages handler")) {
for (msg in newChannel) {
when (msg) {
is ValidationDaemonMsg.ValidateNext -> validateNext()
is ValidationDaemonMsg.RetryValidate -> retryValidate()
is ValidationDaemonMsg.HandleInitEvent -> handleInitEvent(msg.event)
is ValidationDaemonMsg.HandleDestroyEvent -> handleDestroyEvent(msg.event)
is ValidationDaemonMsg.HandleValidationChangeEvent ->
handleValidationChangeEvent(msg.event)
}
}
}
unsubscribeEventsHandler =
formManager.subscribe(
onSubscription = {
for (schemaInfo in formManager.schemaInfo(AbsolutePath.MATCH_ALL)) {
addUnvalidatedPath(schemaInfo.queriedPath, false)
}
}
) { event ->
formManager.scope.launch {
when (event) {
is ValueEvent.Init<*> ->
newChannel.send(ValidationDaemonMsg.HandleInitEvent(event))
is ValueEvent.Destroy<*> ->
newChannel.send(ValidationDaemonMsg.HandleDestroyEvent(event))
is StateEvent.ValidationChange<*> ->
newChannel.send(ValidationDaemonMsg.HandleValidationChangeEvent(event))
else -> {} // Other events are irrelevant
}
}
}
}
/** Stops the validation daemon. */
suspend fun stop() {
if (status.value == AutoValidationStatus.Inactive) {
return
}
// Cancel runnings jobs and channel
channel!!.cancel()
validateAction?.cancel()
messagesHandlerJob!!.cancelAndJoin()
unsubscribeEventsHandler!!()
channel = null
messagesHandlerJob = null
unsubscribeEventsHandler = null
validateAction = null
unvalidatedPaths.clear()
validating = false
validatingPath = null
_status.emit(AutoValidationStatus.Inactive)
FormManager.logger.debug {
"Validation daemon status changed to: ${AutoValidationStatus.Inactive}"
}
}
/**
* Validates the next unvalidated path if there is one. The path being validated is set as
* [validatingPath].
*
* This is the only function that sets [validating] to `false` when there is nothing left to
* validate.
*/
private suspend fun validateNext() {
if (!unvalidatedPaths.isEmpty()) {
val (path, _, id) = unvalidatedPaths.entries.first()
FormManager.logger.trace { "Validation daemon: validating next ($path)" }
unvalidatedPaths.removeEntry(id)
validatingPath = path
validate(path)
} else {
_status.emit(AutoValidationStatus.ActiveIdle)
validating = false
FormManager.logger.debug {
"Validation daemon status changed to: ${AutoValidationStatus.ActiveIdle}"
}
}
}
/**
* Retries validating the [validatingPath].
*
* If no [validatingPath] exists, it means that it was destroyed (and handled by
* [handleDestroyEvent]) so we validate the next unvalidated path instead.
*/
private suspend fun retryValidate() {
if (validatingPath != null) {
FormManager.logger.trace { "Validation daemon: retrying validation ($validatingPath)" }
unvalidatedPaths.remove(validatingPath!!)
validate(validatingPath!!)
} else {
validateNext()
}
}
/**
* Validates [path].
*
* In case of success, schedules the validation of the next unvalidated path. Otherwise, when
* the validation was cancelled, schedules a retry of the current validation.
*/
private fun validate(path: AbsolutePath) {
val channel = channel!!
val action = ValidateAction(formManager, path, { issuesFlow -> issuesFlow.collect() }, 1)
validateAction = action
formManager.scope.launch(CoroutineName("Validation daemon: validate '$path'")) {
try {
// Schedule a validate action with low priority (so it gets cancelled by user-input)
formManager.scheduleActionAndAwait(action)
channel.send(ValidationDaemonMsg.ValidateNext)
} catch (_: CancellationException) {
channel.send(ValidationDaemonMsg.RetryValidate)
}
}
}
private suspend fun handleInitEvent(event: ValueEvent.Init<*>) {
FormManager.logger.trace { "Validation daemon: handling init event (${event.path})" }
if (event.path !in unvalidatedPaths) {
addUnvalidatedPath(event.path)
}
}
private fun handleDestroyEvent(event: ValueEvent.Destroy<*>) {
FormManager.logger.trace { "Validation daemon: handling destroy event (${event.path})" }
removeUnvalidatedPath(event.path + AbsolutePathFragment.RecursiveWildcard)
}
private suspend fun handleValidationChangeEvent(event: StateEvent.ValidationChange<*>) {
FormManager.logger.trace { "Validation daemon: handling validation change event" }
if (
event.status == ValidationStatus.Validated ||
event.status == ValidationStatus.ValidatedExceptionally
) {
removeUnvalidatedPath(event.path)
} else if (
event.status == ValidationStatus.Unvalidated && event.path !in unvalidatedPaths
) {
addUnvalidatedPath(event.path)
}
}
private suspend fun addUnvalidatedPath(path: AbsolutePath, bundlePerSchema: Boolean = true) {
val sizeBeforeAdding = unvalidatedPaths.size
// When there are already too many values of the path's schema needing to be validated, we
// validate all of them at once instead
if (bundlePerSchema) {
val schemaPath = formManager.schemaInfo(path).single().path
if (unvalidatedPaths.entries(schemaPath).count() > PATH_LIMIT_PER_SCHEMA) {
unvalidatedPaths.remove(schemaPath)
unvalidatedPaths[schemaPath] = Unit
} else {
unvalidatedPaths[path] = Unit
}
} else {
unvalidatedPaths[path] = Unit
}
if (!validating && sizeBeforeAdding == 0) {
validating = true
_status.emit(AutoValidationStatus.ActiveRunning)
FormManager.logger.debug {
"Validation daemon status changed to: ${AutoValidationStatus.ActiveRunning}"
}
val channel = channel!!
formManager.scope.launch { channel.send(ValidationDaemonMsg.ValidateNext) }
}
}
private fun removeUnvalidatedPath(path: AbsolutePath) {
unvalidatedPaths.remove(path)
if (validating && validatingPath != null && validatingPath!! in path) {
validatingPath = null
}
}
}