1 | var SDK = require('@ama-team/voxengine-sdk') |
||
2 | var Future = SDK.Concurrent.Future |
||
3 | var Slf4j = SDK.Logger.Slf4j |
||
4 | var Transition = require('./Transition').Transition |
||
5 | var Errors = require('../Error') |
||
6 | var InternalError = Errors.InternalError |
||
0 ignored issues
–
show
Comprehensibility
introduced
by
![]() |
|||
7 | var ScenarioError = Errors.ScenarioError |
||
8 | var Schema = require('../Schema') |
||
9 | var OperationStatus = Schema.OperationStatus |
||
10 | var Normalizer = Schema.Normalizer |
||
11 | var Objects = require('../Utility').Objects |
||
12 | |||
13 | /** |
||
14 | * @param {IExecutor} executor |
||
15 | * @param {TScenario} scenario |
||
16 | * @param {LoggerOptions} [loggerOpts] |
||
17 | * |
||
18 | * @class |
||
19 | */ |
||
20 | function StateMachine (executor, scenario, loggerOpts) { |
||
21 | // this list contains all unfinished transitions; as soon as transition |
||
22 | // has completed or aborted, it is removed from this list |
||
23 | var transitions = [] |
||
24 | var transition = null |
||
25 | var states = scenario.states |
||
26 | var errorHandler = scenario.onError |
||
27 | var state = null |
||
28 | var stage = Stage.Idle |
||
29 | var termination = new Future() |
||
30 | var logger = Slf4j.factory(loggerOpts, 'ama-team.vsf.execution.state-machine') |
||
31 | /** |
||
32 | * @type {TTransitionHistoryEntry[]} |
||
33 | */ |
||
34 | var history = [] |
||
35 | var entrypoint = Object.keys(states).reduce(function (state, key) { |
||
36 | return state || (states[key].entrypoint ? states[key] : null) |
||
37 | }, null) |
||
38 | if (!entrypoint) { |
||
39 | throw new ScenarioError('No entrypoint state has been defined') |
||
40 | } |
||
41 | |||
42 | /** |
||
43 | * @param {StateMachine.Stage} next |
||
44 | */ |
||
45 | function setStage (next) { |
||
46 | logger.debug('Changing status from {} to {}', stage.id, next.id) |
||
47 | stage = next |
||
48 | } |
||
49 | |||
50 | /** |
||
51 | * Saves current transition status |
||
52 | * |
||
53 | * @param {Transition} t8n |
||
54 | * @param {*} [value] Transition value (if it has finished) |
||
55 | */ |
||
56 | function snapshot (t8n, value) { |
||
57 | var origin = t8n.getOrigin() |
||
58 | var entry = { |
||
59 | origin: (origin && origin.id) || null, |
||
60 | target: t8n.getTarget().id, |
||
61 | hints: t8n.getHints(), |
||
62 | status: t8n.getStatus(), |
||
63 | value: value || null |
||
64 | } |
||
65 | history.push(entry) |
||
66 | while (history.length > 100) { |
||
67 | history.shift() |
||
68 | } |
||
69 | } |
||
70 | |||
71 | function requireState (id) { |
||
72 | var state = states[id] |
||
73 | if (state) { |
||
74 | return state |
||
75 | } |
||
76 | var msg = 'Could not find requested state ' + id + ' in provided scenario' |
||
77 | throw new ScenarioError(msg) |
||
78 | } |
||
79 | |||
80 | /** |
||
81 | * Triggers transition to specified state |
||
82 | * |
||
83 | * @param {TState} target |
||
84 | * @param {THints} hints |
||
85 | */ |
||
86 | function transitionTo (target, hints) { |
||
87 | if (stage.terminal) { |
||
88 | var message = 'Can\'t launch new transition from stage ' + stage.id |
||
89 | throw new Errors.IllegalStateError(message) |
||
90 | } |
||
91 | var options = { |
||
92 | logger: loggerOpts, |
||
93 | origin: state, |
||
94 | target: target, |
||
95 | hints: hints || {}, |
||
96 | executor: executor |
||
97 | } |
||
98 | return launch(new Transition(options)) |
||
99 | } |
||
100 | |||
101 | /** |
||
102 | * Aborts current transition (if any) |
||
103 | */ |
||
104 | function abort () { |
||
105 | if (!transition) { |
||
106 | return |
||
107 | } |
||
108 | logger.debug('Aborting current transition {}', transition) |
||
109 | transition.abort() |
||
110 | snapshot(transition) |
||
111 | transition = null |
||
112 | } |
||
113 | |||
114 | /** |
||
115 | * Launches provided transition, aborting running one (if any) and specifying |
||
116 | * any necessary hooks |
||
117 | * |
||
118 | * @param {Transition} t8n |
||
119 | * |
||
120 | * @return {Thenable} |
||
121 | */ |
||
122 | function launch (t8n) { |
||
123 | abort() |
||
124 | transition = t8n |
||
125 | transitions.push(t8n) |
||
126 | snapshot(t8n) |
||
127 | setStage(Stage.Running) |
||
128 | var promise = t8n |
||
129 | .run() |
||
130 | .then(null, function (error) { |
||
131 | logger.error('{} run has rejected', t8n.toString()) |
||
132 | return { |
||
133 | value: error, |
||
134 | status: Transition.Stage.Tripped, |
||
135 | duration: (new Date()).getTime() - t8n.getLaunchedAt().getTime() |
||
136 | } |
||
137 | }) |
||
138 | promise.then(processResult.bind(null, t8n)) |
||
0 ignored issues
–
show
|
|||
139 | return promise |
||
140 | } |
||
141 | |||
142 | /** |
||
143 | * Processes current transition result. |
||
144 | * |
||
145 | * @param {Transition} t8n |
||
146 | * @param {TTransitionResult} result |
||
147 | */ |
||
148 | function processResult (t8n, result) { |
||
149 | logger.debug('{} has finished in {} ms', t8n.toString(), result.duration) |
||
150 | setStage(Stage.Idle) |
||
151 | snapshot(t8n, result.value) |
||
152 | var index = transitions.indexOf(t8n) |
||
153 | transitions = index > -1 ? transitions.splice(index, 1) : transitions |
||
154 | var current = t8n === transition |
||
155 | transition = current ? null : transition |
||
156 | if (!current) { |
||
157 | return result |
||
158 | } |
||
159 | var error = result.value |
||
160 | if (result.status.successful) { |
||
161 | try { |
||
162 | return processSuccess(t8n, result.value) |
||
163 | } catch (e) { |
||
164 | error = e |
||
165 | } |
||
166 | } |
||
167 | processError(t8n, error) |
||
0 ignored issues
–
show
|
|||
168 | } |
||
169 | |||
170 | /** |
||
171 | * Processes transition success. |
||
172 | * |
||
173 | * @param {Transition} t8n |
||
174 | * @param {*} value |
||
175 | */ |
||
176 | function processSuccess (t8n, value) { |
||
177 | logger.debug('{} has resolved with {}, processing', t8n.toString(), value) |
||
178 | value = Normalizer.transition(value) |
||
179 | var destination = t8n.getTarget() |
||
180 | if (value.transitionedTo) { |
||
181 | destination = states[value.transitionedTo] |
||
182 | if (!destination) { |
||
183 | var message = t8n + ' reported transition to state ' + |
||
184 | value.transitionedTo + ' which is not present in scenario states' |
||
185 | throw new ScenarioError(message) |
||
186 | } |
||
187 | } |
||
188 | logger.debug('Transitioned to {}', destination.id) |
||
189 | state = destination |
||
190 | if (state.terminal) { |
||
191 | logger.info('State `{}` is terminal, halting any further processing', |
||
192 | state.id) |
||
193 | terminate(OperationStatus.Finished, value) |
||
194 | return |
||
195 | } |
||
196 | if (!processTrigger(value.trigger || destination.triggers)) { |
||
197 | logger.info('{} didn\'t trigger transition to next state, doing nothing', |
||
198 | t8n) |
||
199 | } |
||
200 | } |
||
201 | |||
202 | function processTrigger (trigger) { |
||
203 | logger.trace('Processing trigger {}', trigger) |
||
204 | trigger = Normalizer.stateTrigger(trigger) |
||
205 | if (!trigger || !trigger.id) { |
||
206 | logger.trace('Trigger did not specify transition to next state') |
||
207 | return false |
||
208 | } |
||
209 | var hints = trigger && trigger.hints |
||
210 | hints = Objects.isFunction(hints) ? executor.execute(hints) : hints |
||
211 | transitionTo(requireState(trigger.id), hints) |
||
212 | return true |
||
213 | } |
||
214 | |||
215 | /** |
||
216 | * Process transition error |
||
217 | * |
||
218 | * @param {Transition} t8n |
||
219 | * @param {Error|*} error |
||
220 | */ |
||
221 | function processError (t8n, error) { |
||
222 | setStage(Stage.ErrorHandling) |
||
223 | if (error instanceof InternalError) { |
||
224 | logger.error('Framework has thrown an error during {}, halting', |
||
225 | t8n.toString()) |
||
226 | return terminate(OperationStatus.Tripped, error) |
||
227 | } |
||
228 | logger.error('{} has finished with error, running error handler', |
||
229 | t8n.toString()) |
||
230 | var originId = (t8n.getOrigin() && t8n.getOrigin().id) || null |
||
231 | var args = [error, originId, t8n.getTarget().id, t8n.getHints()] |
||
232 | executor |
||
233 | .runHandler(errorHandler, args) |
||
234 | .then(function (value) { |
||
235 | return processTrigger(value && value.trigger) |
||
236 | }, function (e) { |
||
237 | logger.error('Outrageous! Error handler has thrown an error ' + |
||
238 | 'itself: {}', e) |
||
239 | }) |
||
240 | .then(function (success) { |
||
241 | if (success) { |
||
242 | logger.notice('Error handler has rescued from {} error', t8n.toString()) |
||
243 | return |
||
244 | } |
||
245 | terminate(OperationStatus.Failed, error) |
||
246 | }) |
||
0 ignored issues
–
show
|
|||
247 | } |
||
248 | |||
249 | /** |
||
250 | * Terminates all processing, forbidding new transitions and resolving |
||
251 | * termination as soon as all transitions will finish |
||
252 | * |
||
253 | * @param {OperationStatus} status |
||
254 | * @param {*} [value] |
||
255 | */ |
||
256 | function terminate (status, value) { |
||
257 | setStage(Stage.Terminating) |
||
258 | logger.debug('Waiting for {} transitions to finish', transitions.length) |
||
259 | var promises = transitions.map(function (transition) { |
||
260 | var silencer = function () {} |
||
261 | return transition.getCompletion().then(silencer, silencer) |
||
262 | }) |
||
263 | Promise.all(promises).then(function () { |
||
264 | setStage(Stage.Terminated) |
||
265 | termination.resolve({ |
||
266 | status: status, |
||
267 | value: value || null |
||
268 | }) |
||
269 | }) |
||
270 | } |
||
271 | |||
272 | this.terminate = function () { |
||
273 | if (stage.terminal) { |
||
274 | var message = 'Can not terminate non-active state machine' |
||
275 | throw new Errors.IllegalStateError(message) |
||
276 | } |
||
277 | abort() |
||
278 | terminate(OperationStatus.Aborted, null) |
||
279 | return termination |
||
280 | } |
||
281 | |||
282 | /** |
||
283 | * Returns current states |
||
284 | * |
||
285 | * @return {TState} |
||
286 | */ |
||
287 | this.getState = function () { |
||
288 | return state |
||
289 | } |
||
290 | |||
291 | /** |
||
292 | * |
||
293 | * @return {Transition[]} |
||
294 | */ |
||
295 | this.getTransitions = function () { |
||
296 | return transitions.slice() |
||
297 | } |
||
298 | |||
299 | /** |
||
300 | * |
||
301 | * @return {Transition} |
||
302 | */ |
||
303 | this.getTransition = function () { |
||
304 | return transition |
||
305 | } |
||
306 | |||
307 | /** |
||
308 | * @param {TStateId} id |
||
309 | * @param {THints} [hints] |
||
310 | * |
||
311 | * @return {Thenable} |
||
312 | */ |
||
313 | this.transitionTo = function (id, hints) { |
||
314 | if (stage.restricted) { |
||
315 | var message = 'State machine is in ' + stage.id + ' state ' + |
||
316 | 'and doesn\'t accept #transitionTo() calls' |
||
317 | throw new ScenarioError(message) |
||
318 | } |
||
319 | return transitionTo(requireState(id), hints) |
||
320 | } |
||
321 | |||
322 | /** |
||
323 | * Runs state machine |
||
324 | * |
||
325 | * @param {THints} [hints] |
||
326 | * @return {Thenable.<TStateMachineResult>} |
||
327 | */ |
||
328 | this.run = function (hints) { |
||
329 | transitionTo(entrypoint, hints) |
||
330 | return termination |
||
331 | } |
||
332 | |||
333 | /** |
||
334 | * @return {StateMachine.Stage} |
||
335 | */ |
||
336 | this.getStatus = function () { |
||
337 | return stage |
||
338 | } |
||
339 | |||
340 | /** |
||
341 | * Returns 100 last transition history events |
||
342 | * |
||
343 | * @return {TTransitionHistoryEntry[]} |
||
344 | */ |
||
345 | this.getHistory = function () { |
||
346 | return history |
||
347 | } |
||
348 | |||
349 | /** |
||
350 | * Returns termination handle |
||
351 | * |
||
352 | * @return {Thenable.<TStateMachineResult>} |
||
353 | */ |
||
354 | this.getTermination = function () { |
||
355 | return termination |
||
356 | } |
||
357 | } |
||
358 | |||
359 | /** |
||
360 | * @typedef {object} StateMachine.Stage~Instance |
||
361 | * |
||
362 | * @property {string} id |
||
363 | * @property {boolean} restricted |
||
364 | * @property {boolean} terminal |
||
365 | */ |
||
366 | |||
367 | /** |
||
368 | * @param {string} id |
||
369 | * @param {boolean} [restricted] |
||
370 | * @param {boolean} [terminal] |
||
371 | * @return {StateMachine.Stage~Instance} |
||
372 | */ |
||
373 | var stageFactory = function (id, restricted, terminal) { |
||
374 | terminal = typeof terminal === 'boolean' ? terminal : restricted |
||
375 | return { |
||
376 | id: id, |
||
377 | restricted: restricted, |
||
378 | terminal: terminal |
||
379 | } |
||
380 | } |
||
381 | |||
382 | /** |
||
383 | * @enum {StateMachine.Stage~Instance} |
||
384 | * @readonly |
||
385 | */ |
||
386 | StateMachine.Stage = { |
||
387 | Idle: stageFactory('Idle'), |
||
388 | Running: stageFactory('Running'), |
||
389 | ErrorHandling: stageFactory('ErrorHandling', true, false), |
||
390 | Terminating: stageFactory('Terminating', true), |
||
391 | Terminated: stageFactory('Terminated', true) |
||
392 | } |
||
393 | |||
394 | var Stage = StateMachine.Stage |
||
395 | |||
396 | module.exports = { |
||
397 | StateMachine: StateMachine |
||
398 | } |
||
399 |