Skip to content

Commit 40d1d54

Browse files
committed
Add RestorationWarningException
1 parent 40d7f58 commit 40d1d54

4 files changed

Lines changed: 139 additions & 20 deletions

File tree

kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/persistence/EventRecorder.kt

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ data class RestorationResult(
6262
data class RestoredEventResult(
6363
val record: Record,
6464
val processingResult: Result<ProcessingResult>,
65-
val warnings: List<Exception>,
65+
val warnings: List<RestorationWarningException>,
6666
)
6767

6868
fun interface RestorationResultValidator {
@@ -87,24 +87,36 @@ object StrictValidator : RestorationResultValidator {
8787
result.results.forEach {
8888
if (it.warnings.isNotEmpty()) {
8989
throw RestorationResultValidationException(
90-
"The ${RestorationResult::class.simpleName} contains warnings",
9190
result,
91+
"The ${RestorationResult::class.simpleName} contains warnings",
9292
)
9393
}
9494
if (it.processingResult.isFailure) {
9595
throw RestorationResultValidationException(
96-
"The ${RestorationResult::class.simpleName} contains failed processing result",
9796
result,
97+
"The ${RestorationResult::class.simpleName} contains failed processing result",
9898
)
9999
}
100100
}
101101
}
102102
}
103103

104104
class RestorationResultValidationException(
105+
val result: RestorationResult,
105106
message: String,
106-
val result: RestorationResult
107-
) : RuntimeException(message)
107+
cause: Throwable? = null,
108+
) : RuntimeException(message, cause)
109+
110+
enum class WarningType {
111+
ProcessingResultNotMatch,
112+
PendingEventMightBeIgnored,
113+
}
114+
115+
class RestorationWarningException(
116+
val warningType: WarningType,
117+
message: String,
118+
cause: Throwable? = null,
119+
) : RuntimeException(message, cause)
108120

109121
/**
110122
* Processes [RecordedEvents] with purpose of restoring a [StateMachine] to a state configuration as it was before.
@@ -170,14 +182,29 @@ suspend fun StateMachine.restoreRunningMachineByRecordedEvents(
170182
val mutationSection = if (muteListeners) openListenersMutationSection() else EmptyListenersMutationSection
171183
mutationSection.use {
172184
for (record in recordedEvents.records) {
173-
val warnings = mutableListOf<Exception>()
185+
val warnings = mutableListOf<RestorationWarningException>()
174186
val (event, argument) = record.eventAndArgument
175187
if (event is StartEvent)
176188
continue // fixme вызов start мог иметь argument что с ним делать?
177189
val processingResult = runCatching { processEvent(event, argument) }
178-
val actualResult = processingResult.getOrNull() // fixme может вернуться panding, надо пропускать?
179-
if (actualResult != null && actualResult != record.processingResult)
180-
warnings += IllegalStateException("Recorded and actual processing results does not match")
190+
val actualResult = processingResult.getOrNull()
191+
if (actualResult != null && actualResult != record.processingResult) {
192+
if (actualResult == ProcessingResult.PENDING) {
193+
if (pendingEventHandler !is QueuePendingEventHandler)
194+
warnings += RestorationWarningException(
195+
WarningType.PendingEventMightBeIgnored,
196+
"Actual result is ${ProcessingResult.PENDING}, " +
197+
"but the ${StateMachine::class.simpleName} is NOT configured " +
198+
"with ${QueuePendingEventHandler::class::simpleName}, which potentially means that " +
199+
"the event {${record.eventAndArgument.event}} might be silently ignored",
200+
)
201+
} else {
202+
warnings += RestorationWarningException(
203+
WarningType.ProcessingResultNotMatch,
204+
"Recorded (${record.processingResult}) and actual ($actualResult) processing results does not match",
205+
)
206+
}
207+
}
181208
results += RestoredEventResult(record, processingResult, warnings)
182209
}
183210
}

kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/PendingEventHandler.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ interface QueuePendingEventHandler : StateMachine.PendingEventHandler {
2424
private class QueuePendingEventHandlerImpl(private val machine: StateMachine) : QueuePendingEventHandler {
2525
private val queue = ArrayDeque<EventAndArgument<*>>()
2626

27-
override suspend fun checkEmpty() = check(queue.isEmpty()) { "Event queue is not empty, internal error" }
27+
override suspend fun checkEmpty() = check(queue.isEmpty()) {
28+
"Event queue is not empty, internal error (should never happen). " +
29+
"Double check that you don't break multi-threading usage rules, if you see this error. " +
30+
"Usually it is related to concurrent collection modification."
31+
}
2832

2933
override suspend fun onPendingEvent(eventAndArgument: EventAndArgument<*>) {
3034
machine.log {

kstatemachine/src/commonMain/kotlin/ru/nsk/kstatemachine/statemachine/StateMachine.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ interface StateMachine : State {
178178
val requireNonBlankNames: Boolean = false,
179179
/**
180180
* If set, enables incoming events recording in order to restore [StateMachine] later.
181+
* By default, event recording is disabled.
181182
* Use [StateMachine.eventRecorder] to access the recording result.
182183
*/
183184
val eventRecordingArguments: EventRecordingArguments? = null

tests/src/commonTest/kotlin/ru/nsk/kstatemachine/persistence/RestoreByRecordedEventsTest.kt

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import io.kotest.assertions.throwables.shouldNotThrowAny
44
import io.kotest.assertions.throwables.shouldThrow
55
import io.kotest.assertions.throwables.shouldThrowWithMessage
66
import io.kotest.core.spec.style.StringSpec
7+
import io.kotest.matchers.collections.shouldContainExactly
8+
import io.kotest.matchers.shouldBe
79
import io.mockk.called
810
import io.mockk.verifySequence
911
import ru.nsk.kstatemachine.*
10-
import ru.nsk.kstatemachine.state.initialState
11-
import ru.nsk.kstatemachine.state.transition
12+
import ru.nsk.kstatemachine.state.*
1213
import ru.nsk.kstatemachine.statemachine.StateMachine
14+
import ru.nsk.kstatemachine.statemachine.StateMachine.*
1315
import ru.nsk.kstatemachine.statemachine.destroy
1416

1517
class RestoreByRecordedEventsTest : StringSpec({
@@ -68,7 +70,7 @@ class RestoreByRecordedEventsTest : StringSpec({
6870
"check event restoration on different machines without structure check" {
6971
val machine1 = createTestStateMachine(
7072
coroutineStarterType,
71-
creationArguments = StateMachine.CreationArguments(eventRecordingArguments = StateMachine.EventRecordingArguments())
73+
creationArguments = CreationArguments(eventRecordingArguments = EventRecordingArguments())
7274
) {
7375
initialState()
7476
}
@@ -85,7 +87,7 @@ class RestoreByRecordedEventsTest : StringSpec({
8587
"negative check event restoration on different machines throws" {
8688
val machine1 = createTestStateMachine(
8789
coroutineStarterType,
88-
creationArguments = StateMachine.CreationArguments(eventRecordingArguments = StateMachine.EventRecordingArguments())
90+
creationArguments = CreationArguments(eventRecordingArguments = EventRecordingArguments())
8991
) {
9092
initialState()
9193
}
@@ -102,15 +104,15 @@ class RestoreByRecordedEventsTest : StringSpec({
102104
"check event recording preconditions with structure check" {
103105
val machine1 = createTestStateMachine(
104106
coroutineStarterType,
105-
creationArguments = StateMachine.CreationArguments(eventRecordingArguments = StateMachine.EventRecordingArguments())
107+
creationArguments = CreationArguments(eventRecordingArguments = EventRecordingArguments())
106108
) {
107109
initialState()
108110
}
109111
val recordedEvents = machine1.eventRecorder.getRecordedEvents()
110112

111113
val machine2 = createTestStateMachine(
112114
coroutineStarterType,
113-
creationArguments = StateMachine.CreationArguments(eventRecordingArguments = StateMachine.EventRecordingArguments())
115+
creationArguments = CreationArguments(eventRecordingArguments = EventRecordingArguments())
114116
) {
115117
initialState()
116118
}
@@ -122,7 +124,7 @@ class RestoreByRecordedEventsTest : StringSpec({
122124

123125
val machine1 = createTestStateMachine(
124126
coroutineStarterType,
125-
creationArguments = StateMachine.CreationArguments(eventRecordingArguments = StateMachine.EventRecordingArguments())
127+
creationArguments = CreationArguments(eventRecordingArguments = EventRecordingArguments())
126128
) {
127129
initialState()
128130
transition<SwitchEvent>()
@@ -132,7 +134,7 @@ class RestoreByRecordedEventsTest : StringSpec({
132134

133135
val machine2 = createTestStateMachine(
134136
coroutineStarterType,
135-
creationArguments = StateMachine.CreationArguments(eventRecordingArguments = StateMachine.EventRecordingArguments())
137+
creationArguments = CreationArguments(eventRecordingArguments = EventRecordingArguments())
136138
) {
137139
initialState()
138140
transition<SwitchEvent> {
@@ -150,7 +152,7 @@ class RestoreByRecordedEventsTest : StringSpec({
150152

151153
val machine1 = createTestStateMachine(
152154
coroutineStarterType,
153-
creationArguments = StateMachine.CreationArguments(eventRecordingArguments = StateMachine.EventRecordingArguments())
155+
creationArguments = CreationArguments(eventRecordingArguments = EventRecordingArguments())
154156
) {
155157
initialState()
156158
transition<SwitchEvent>()
@@ -160,7 +162,7 @@ class RestoreByRecordedEventsTest : StringSpec({
160162

161163
val machine2 = createTestStateMachine(
162164
coroutineStarterType,
163-
creationArguments = StateMachine.CreationArguments(eventRecordingArguments = StateMachine.EventRecordingArguments())
165+
creationArguments = CreationArguments(eventRecordingArguments = EventRecordingArguments())
164166
) {
165167
initialState()
166168
transition<SwitchEvent> {
@@ -172,5 +174,90 @@ class RestoreByRecordedEventsTest : StringSpec({
172174
callbacks.onTransitionTriggered(SwitchEvent)
173175
}
174176
}
177+
178+
"restore the machine that is not running yet (processes all events as pending)" {
179+
val machine1 = createTestStateMachine(
180+
coroutineStarterType,
181+
creationArguments = CreationArguments(eventRecordingArguments = EventRecordingArguments())
182+
) {
183+
initialState()
184+
val state = state()
185+
transition<SwitchEvent>(targetState = state)
186+
}
187+
machine1.processEvent(SwitchEvent)
188+
val recordedEvents = machine1.eventRecorder.getRecordedEvents()
189+
190+
lateinit var state: State
191+
val machine2 = createTestStateMachine(
192+
coroutineStarterType,
193+
start = false,
194+
creationArguments = CreationArguments(eventRecordingArguments = EventRecordingArguments())
195+
) {
196+
initialState()
197+
state = state()
198+
transition<SwitchEvent>(targetState = state)
199+
}
200+
machine2.restoreByRecordedEvents(recordedEvents, muteListeners = false)
201+
machine2.start()
202+
machine2.activeStates().shouldContainExactly(state)
203+
}
204+
205+
"restore the machine that is not running yet with non queued PendingEventHandler (processes all events as pending)" {
206+
val machine1 = createTestStateMachine(
207+
coroutineStarterType,
208+
creationArguments = CreationArguments(eventRecordingArguments = EventRecordingArguments())
209+
) {
210+
pendingEventHandler = PendingEventHandler {}
211+
initialState()
212+
val state = state()
213+
transition<SwitchEvent>(targetState = state)
214+
}
215+
machine1.processEvent(SwitchEvent)
216+
val recordedEvents = machine1.eventRecorder.getRecordedEvents()
217+
218+
lateinit var state: State
219+
val machine2 = createTestStateMachine(
220+
coroutineStarterType,
221+
start = false,
222+
creationArguments = CreationArguments(eventRecordingArguments = EventRecordingArguments())
223+
) {
224+
pendingEventHandler = PendingEventHandler {}
225+
initialState()
226+
state = state()
227+
transition<SwitchEvent>(targetState = state)
228+
}
229+
machine2.restoreByRecordedEvents(recordedEvents, muteListeners = false)
230+
val exception = shouldThrow<RestorationResultValidationException> {
231+
machine2.start()
232+
}
233+
exception.result.results.single().warnings.single().warningType shouldBe WarningType.PendingEventMightBeIgnored
234+
}
235+
236+
"restore the machine that is not running yet with default QueuedPendingEventHandler (processes all events as pending)" {
237+
val machine1 = createTestStateMachine(
238+
coroutineStarterType,
239+
creationArguments = CreationArguments(eventRecordingArguments = EventRecordingArguments())
240+
) {
241+
initialState()
242+
val state = state()
243+
transition<SwitchEvent>(targetState = state)
244+
}
245+
machine1.processEvent(SwitchEvent)
246+
val recordedEvents = machine1.eventRecorder.getRecordedEvents()
247+
248+
lateinit var state: State
249+
val machine2 = createTestStateMachine(
250+
coroutineStarterType,
251+
start = false,
252+
creationArguments = CreationArguments(eventRecordingArguments = EventRecordingArguments())
253+
) {
254+
initialState()
255+
state = state()
256+
transition<SwitchEvent>(targetState = state)
257+
}
258+
machine2.restoreByRecordedEvents(recordedEvents, muteListeners = false)
259+
machine2.start()
260+
machine2.activeStates().shouldContainExactly(state)
261+
}
175262
}
176263
})

0 commit comments

Comments
 (0)