Skip to content

Commit

Permalink
Add EventRecordingArguments
Browse files Browse the repository at this point in the history
  • Loading branch information
nsk90 committed Mar 24, 2024
1 parent b61b4ab commit 2709b8c
Show file tree
Hide file tree
Showing 8 changed files with 260 additions and 92 deletions.
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,27 +35,37 @@ Integration features are:

State management features:

* **Event based** - [transitions](https://kstatemachine.github.io/kstatemachine/#setup-transitions) are performed by processing
* **Event based** - [transitions](https://kstatemachine.github.io/kstatemachine/#setup-transitions) are performed by
processing
incoming events
* **[Reactive](https://kstatemachine.github.io/kstatemachine/#listen-states)** - listen for machine, states,
[state groups](https://kstatemachine.github.io/kstatemachine/#listen-group-of-states) and transitions
* **[Guarded](https://kstatemachine.github.io/kstatemachine/#guarded-transitions)
and [Conditional transitions](https://kstatemachine.github.io/kstatemachine/#conditional-transitions)** - dynamic target
and [Conditional transitions](https://kstatemachine.github.io/kstatemachine/#conditional-transitions)** - dynamic
target
state which is calculated in a moment of event processing depending on application business logic
* **[Nested states](https://kstatemachine.github.io/kstatemachine/#nested-states)** - build hierarchical state machines
(statecharts)
with [cross-level transitions](https://kstatemachine.github.io/kstatemachine/#cross-level-transitions) support
* **[Composed (nested) state machines.](https://kstatemachine.github.io/kstatemachine/#composed-(nested)-state-machines)** Use
* **[Composed (nested) state machines.](https://kstatemachine.github.io/kstatemachine/#composed-(nested)-state-machines)
** Use
state machines as atomic child states
* **[Pseudo states](https://kstatemachine.github.io/kstatemachine/#pseudo-states)** for additional logic in machine behaviour
* **[Typesafe transitions](https://kstatemachine.github.io/kstatemachine/#typesafe-transitions)** - pass data in typesafe way
* **[Pseudo states](https://kstatemachine.github.io/kstatemachine/#pseudo-states)** for additional logic in machine
behaviour
* **[Typesafe transitions](https://kstatemachine.github.io/kstatemachine/#typesafe-transitions)** - pass data in
typesafe way
from event to state
* **[Parallel states](https://kstatemachine.github.io/kstatemachine/#parallel-states)** - avoid a combinatorial explosion of
* **[Parallel states](https://kstatemachine.github.io/kstatemachine/#parallel-states)** - avoid a combinatorial
explosion of
states
* **[Undo transitions](https://kstatemachine.github.io/kstatemachine/#undo-transitions)** - navigate back to previous state
* **[Optional argument](https://kstatemachine.github.io/kstatemachine/#optinal-arguments)** passing for events and transitions
* **[Undo transitions](https://kstatemachine.github.io/kstatemachine/#undo-transitions)** - navigate back to previous
state
* **[Optional argument](https://kstatemachine.github.io/kstatemachine/#optinal-arguments)** passing for events and
transitions
* **[Export](https://kstatemachine.github.io/kstatemachine/#export)** state machine structure
to [PlantUML](https://plantuml.com/) and [Mermaid](https://mermaid.js.org/) diagrams
* **[Persist (serialize)](https://kstatemachine.github.io/kstatemachine/#persistence)** state machine's active
configuration and restore it later
* **[Logging](https://kstatemachine.github.io/kstatemachine/#logging)** - useful for debugging
* **[Testable](https://kstatemachine.github.io/kstatemachine/#testing)** - run state machine from specified state
* **Well tested** - all features are covered
Expand Down
34 changes: 32 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
* [PlantUML](#plantuml)
* [Mermaid](#mermaid)
* [Controlling export output](#controlling-export-output)
* [Persistence](#persistence)
* [Event recording](#event-recording)
* [Testing](#testing)
* [Multiplatform](#multiplatform)
* [Consider using Kotlin sealed classes](#consider-using-kotlin-sealed-classes)
Expand Down Expand Up @@ -423,7 +425,7 @@ like this `machine.processEvent(UndoEvent)`. State Machine will roll back last t
to previous state (except target-less transitions).
This API might be called as many times as needed.
To implement this feature library stores transitions in a stack, it takes memory,
so this feature is disabled by default and must be enabled explicitly using
so this feature is disabled by default and must be enabled explicitly using
`createStateMachine(creationArguments = CreationArguments(isUndoEnabled = true))` argument.

Undo functionality is implemented as `Event`, so it possible to call `undo()` from notification callbacks, if you use
Expand Down Expand Up @@ -867,7 +869,7 @@ conditions, it is not correct.
Even `Dispatchers.Default.limitedParallelism(1)` that seems to be ok at glance,
does not provide guarantee that each coroutine will be executed on the same single thread, it only limits the amount of
used threads. So race condition still takes place, as nothing forces threads, running on different processor cores,
to update variable values in their processor core caches, so outdated values could be used from core cache. Other words,
to update variable values in their processor core caches, so outdated values could be used from core cache. Other words,
one thread does not to know about variable changes made by other one. This known as __visibility guarantee__,
that `volatile` keyword provides on `jvm`.
Expand Down Expand Up @@ -1024,6 +1026,34 @@ state("State1") {
See [PlantUML with MetaInfo export sample](https://github.com/nsk90/kstatemachine/tree/master/samples/src/commonMain/kotlin/ru/nsk/samples/PlantUmlExportWithMetaInfoSample.kt)
## Persistence
**Persist** `StateMachine` - means transform it into serializable representation, such as `Serializable` object or
JSON text, and possibly saving it into some persistent storage like file or sending by network.
**Restoration** is a process of restoring the `StateMachine` from the serializable representation.
There are several kinds or levels of `StateMachine` persistence (serialization). Let's look at sample use cases:

1) **Structure + configuration** - Create `StateMachine` on some process/host and send its structure and
active configuration by network to another process/host.
The receiver can dynamically create the same `StateMachine` instance in the same state as original one.
2) **Configuration only** - Both original and restored `StateMachine` instances are crated by identical static code
(in a single or multiple different processes/hosts). Only active configuration can be saved and restored.

Case 1 currently lacks built-in support by the library (you can open an issue if you need something like that).
Case 2 in turn may be reached in two different ways:

1) **Persisting state** - serializing all internal data, active states, variables etc. from original `StateMachine` and
applying them to restored one.
2) **Event recording** - serializing all incoming events, and applying them later on new `StateMachine` instance,
which should lead it into the same state as original. This also allows to execute library callbacks (listeners)
if necessary, which is not possible with state persistence approach.
_Currently only this approach has built-in support._

### Event recording

WIP

## Testing

For testing, it might be useful to check how state machine reacts on events from particular state. There
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package ru.nsk.kstatemachine.persist
package ru.nsk.kstatemachine.persistence

import ru.nsk.kstatemachine.event.DestroyEvent
import ru.nsk.kstatemachine.event.StartEvent
import ru.nsk.kstatemachine.event.StopEvent
import ru.nsk.kstatemachine.statemachine.*
import ru.nsk.kstatemachine.statemachine.StateMachine.EventRecordingArguments
import ru.nsk.kstatemachine.transition.EventAndArgument
import ru.nsk.kstatemachine.visitors.structureHashCode

Expand All @@ -17,20 +21,37 @@ interface EventRecorder {
*/
data class RecordedEvents(
val structureHashCode: Int,
val events: List<EventAndArgument<*>>,
val records: List<Record>,
)

data class Record(
val eventAndArgument: EventAndArgument<*>,
val processingResult: ProcessingResult,
)

internal class EventRecorderImpl(
private val machine: StateMachine
private val machine: StateMachine,
private val arguments: EventRecordingArguments
) : EventRecorder {
private val events = mutableListOf<EventAndArgument<*>>()

fun onProcessEvent(eventAndArgument: EventAndArgument<*>) {
events += eventAndArgument
private val records = mutableListOf<Record>()

/**
* Should be called with not wrapped event.
* Should not be called on [ProcessingResult.PENDING] events.
*/
fun onProcessEvent(eventAndArgument: EventAndArgument<*>, processingResult: ProcessingResult) {
val lastEvent = records.lastOrNull()?.eventAndArgument?.event
check(lastEvent !is DestroyEvent) {
"Internal error, ${::onProcessEvent::name} called after " +
"${DestroyEvent::class.simpleName} processing, which is considered as last possible event"
}
if (arguments.skipIgnoredEvents && processingResult == ProcessingResult.IGNORED) return
if (arguments.clearRecordsOnMachineRestart && lastEvent is StopEvent) records.clear()
records += Record(eventAndArgument, processingResult)
}

override fun getRecordedEvents(): RecordedEvents {
return RecordedEvents(machine.structureHashCode, events)
return RecordedEvents(machine.structureHashCode, records)
}
}

Expand All @@ -39,7 +60,7 @@ data class RestorationResult(
)

data class RestoredEventResult(
val event: EventAndArgument<*>,
val record: Record,
val processingResult: Result<ProcessingResult>,
)

Expand Down Expand Up @@ -96,12 +117,18 @@ suspend fun StateMachine.restoreRunningMachineByRecordedEvents(

val results = mutableListOf<RestoredEventResult>()
mutationSection.use {
recordedEvents.events.forEach {
val processingResult = runCatching { processEvent(it.event, it.argument) }
results += RestoredEventResult(it, processingResult)
recordedEvents.records.forEach {
val (event, argument) = it.eventAndArgument
if (event is StartEvent && !isRunning) { // fixme always false isRunning is checked on method entry
start(argument)
results += RestoredEventResult(it, Result.success(ProcessingResult.PROCESSED))
} else {
val processingResult = runCatching { processEvent(event, argument) }
results += RestoredEventResult(it, processingResult)
}
}
}
RestorationResult(results)
RestorationResult(results)// fixme check processingResults are equal
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import ru.nsk.kstatemachine.state.IState
import ru.nsk.kstatemachine.transition.EventAndArgument
import ru.nsk.kstatemachine.transition.TransitionParams

private data class StateAndEvent(val states: Set<IState>, val eventAndArgument: EventAndArgument<*>)
private data class StateAndEvent(val targetStates: Set<IState>, val eventAndArgument: EventAndArgument<*>)

internal class UndoState : BasePseudoState("undoState") {
private val stack = mutableListOf<StateAndEvent>()
Expand All @@ -22,7 +22,7 @@ internal class UndoState : BasePseudoState("undoState") {
}

/**
* Called before [popState]
* Called before [popTargetStates]
*/
fun makeWrappedEvent(): WrappedEvent {
val element = stack.getOrNull(stack.size - 2)
Expand All @@ -32,9 +32,9 @@ internal class UndoState : BasePseudoState("undoState") {
WrappedEvent(UndoEvent, null)
}

fun popState(): Set<IState> = if (stack.size >= 2) {
fun popTargetStates(): Set<IState> = if (stack.size >= 2) {
stack.removeLast()
stack.last().states
stack.last().targetStates
} else {
emptySet()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import ru.nsk.kstatemachine.event.DestroyEvent
import ru.nsk.kstatemachine.event.Event
import ru.nsk.kstatemachine.event.StopEvent
import ru.nsk.kstatemachine.event.UndoEvent
import ru.nsk.kstatemachine.persist.EventRecorder
import ru.nsk.kstatemachine.persistence.EventRecorder
import ru.nsk.kstatemachine.state.ChildMode
import ru.nsk.kstatemachine.state.IState
import ru.nsk.kstatemachine.state.State
Expand Down Expand Up @@ -177,10 +177,21 @@ interface StateMachine : State {
*/
val requireNonBlankNames: Boolean = false,
/**
* Enables incoming events recording in order to restore [StateMachine] later.
* If set, enables incoming events recording in order to restore [StateMachine] later.
* Use [StateMachine.eventRecorder] to access the recording result.
*/
val recordEvents: Boolean = false,
val eventRecordingArguments: EventRecordingArguments? = null
)

data class EventRecordingArguments(
/**
* If enabled removes all recorded events when detects that the machine was stopped and started again.
*/
val clearRecordsOnMachineRestart: Boolean = true,
/**
* If enabled skips ignored events, supposing they do not affect restoration of the machine
*/
val skipIgnoredEvents: Boolean = true,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ package ru.nsk.kstatemachine.statemachine
import ru.nsk.kstatemachine.coroutines.CoroutineAbstraction
import ru.nsk.kstatemachine.event.*
import ru.nsk.kstatemachine.isSubStateOf
import ru.nsk.kstatemachine.persist.EventRecorder
import ru.nsk.kstatemachine.persist.EventRecorderImpl
import ru.nsk.kstatemachine.persistence.EventRecorder
import ru.nsk.kstatemachine.persistence.EventRecorderImpl
import ru.nsk.kstatemachine.state.*
import ru.nsk.kstatemachine.state.pseudo.UndoState
import ru.nsk.kstatemachine.statemachine.ProcessingResult.*
import ru.nsk.kstatemachine.statemachine.StateMachine.CreationArguments
import ru.nsk.kstatemachine.transition.*
import ru.nsk.kstatemachine.transition.TransitionDirectionProducerPolicy.DefaultPolicy
Expand Down Expand Up @@ -63,14 +64,14 @@ internal class StateMachineImpl(
private var _areListenersMuted = false
override val areListenersMuted get() = _areListenersMuted

private val _eventRecorder = if (creationArguments.recordEvents) EventRecorderImpl(this) else null
private val _eventRecorder = creationArguments.eventRecordingArguments?.let {
EventRecorderImpl(this, it)
}

override val eventRecorder: EventRecorder
get() {
check(creationArguments.recordEvents) {
"Event recording is not enabled. Use ${CreationArguments::recordEvents.name} parameter " +
"of createStateMachine() method family"
}
return _eventRecorder!!
get() = checkNotNull(_eventRecorder) {
"Event recording is not enabled. Use ${CreationArguments::eventRecordingArguments.name} parameter " +
"of createStateMachine() method family, to enable it first"
}

init {
Expand Down Expand Up @@ -213,47 +214,50 @@ internal class StateMachineImpl(
* Should be called only from [runCheckingExceptions]
*/
private suspend fun processStep1(eventAndArgument: EventAndArgument<*>): Step1Result {
_eventRecorder?.onProcessEvent(eventAndArgument) // should be called with not wrapped event

val wrappedEventAndArgument = eventAndArgument.wrap()
val eventProcessed = when (val event = wrappedEventAndArgument.event) {
is StopEvent -> {
doStop()
true
}

is DestroyEvent -> {
if (event.stop && isRunning) doStop()
doDestroy()
true
}

else -> doProcessEvent(wrappedEventAndArgument)
}
return Step1Result(eventProcessed, wrappedEventAndArgument)
val processingResult = if (eventProcessed) PROCESSED else IGNORED
_eventRecorder?.onProcessEvent(eventAndArgument, processingResult)
return Step1Result(wrappedEventAndArgument, processingResult)
}

/**
* Possible exceptions from this step should reach the user (caller)
* So it should not be called from [runCheckingExceptions]
*/
private suspend fun processStep2(
eventAndArgument: EventAndArgument<*>,
step1Result: Step1Result,
): ProcessingResult {
return if (step1Result.eventProcessed) {
if (eventAndArgument.event !is StartEvent)
_hasProcessedEvents = true
ProcessingResult.PROCESSED
} else {
log { "$this ignored ${step1Result.wrappedEventAndArgument.event::class.simpleName}" }
ignoredEventHandler.onIgnoredEvent(step1Result.wrappedEventAndArgument)
ProcessingResult.IGNORED
when (step1Result.processingResult) {
PROCESSED -> {
if (eventAndArgument.event !is StartEvent)
_hasProcessedEvents = true
}
IGNORED -> {
log { "$this ignored ${step1Result.wrappedEventAndArgument.event::class.simpleName}" }
ignoredEventHandler.onIgnoredEvent(step1Result.wrappedEventAndArgument)
}
PENDING -> error("Internal error, $PENDING is not expected here")
}
return step1Result.processingResult
}

/**
* Runs block of code that processes event, and processes all pending events from queue after it if
* [QueuePendingEventHandler] is used.
* This method is transparent for exceptions.
*/
private suspend fun <R> eventProcessingScope(block: suspend () -> R): R {
val queue = pendingEventHandler as? QueuePendingEventHandler
Expand Down Expand Up @@ -409,6 +413,6 @@ private fun StateMachine.checkPropertyNotMutedOnRunningMachine(propertyType: KCl
check(!isRunning) { "Can not change ${propertyType.simpleName} after state machine started" }

private data class Step1Result(
val eventProcessed: Boolean,
val wrappedEventAndArgument: EventAndArgument<*>,
val processingResult: ProcessingResult,
)
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,16 @@ private suspend fun EventAndArgument<*>.resolveTargetState(targetState: IState):
return if (resolvedState != null) TargetState(setOf(resolvedState)) else NoTransition
}

/**
* @return state or null, which means no transition
*/
private suspend fun EventAndArgument<*>.recursiveResolveTargetState(targetState: IState): IState? {
val resolvedTarget = when (targetState) {
// We can return here to optimize out double initialPseudoState resolution,
// as initialPseudoState resolution is already done inside RedirectPseudoState::resolveTargetState()
is RedirectPseudoState -> return targetState.resolveTargetState(DefaultPolicy(this)).targetState
is HistoryState -> targetState.storedState
is UndoState -> targetState.popState().firstOrNull() // fixme this is a bug, should use all set items, add test for undo multi-target transition
is UndoState -> targetState.popTargetStates().firstOrNull() // fixme this is a bug, should use all set items, add test for undo multi-target transition
else -> targetState
}
// when target state calculated we need to check if its entry will trigger another redirection
Expand All @@ -106,7 +109,7 @@ private suspend fun EventAndArgument<*>.recursiveResolveTargetState(targetState:
val initialPseudoState = resolvedTarget.findInitialPseudoState()
if (initialPseudoState == null) resolvedTarget else recursiveResolveTargetState(initialPseudoState)
} else {
null // means no transition
null
}
}

Expand Down
Loading

0 comments on commit 2709b8c

Please sign in to comment.