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

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

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




© 2015 - 2024 Weber Informatics LLC | Privacy Policy