xstate
xstate copied to clipboard
machine ignores invocation when starting from arbitrary state
Description
When explicitly starting the machine via the interpreter from a state which defines an invocation, the machine does not transition from that state, regardless of the outcome -resolve or reject-. Starting from the baked-in initial state, the interpreter and machine working as expected.
const machine = Machine({
id: "machine",
initial: "S1",
states: {
S1: {
invoke: {
src: () => {
return new Promise((resolve) => setTimeout(resolve, 1000));
},
onDone: "S2"
}
},
S2: {
invoke: {
src: () => {
return new Promise((resolve) => setTimeout(resolve, 1000));
},
onDone: "S1"
}
}
}
});
const service = interpret(machine).onTransition((state) => {
console.log(state.value);
});
service.start(); // working as expected, 'S1, S2, S1, S2, ...'
// service.start("S1"); // it stays in 'S1'
Expected Result
The interpreter and machine should be indifferent to the initialState.
Actual Result
However, this is not the case, according to these examples. I hope I'm not messing something up, which causes this unexpected behaviour.
Reproduction
- CodeSandbox (starting without argument) (JavaScript) :heavy_check_mark:
- CodeSandbox (starting with initalState defined) (JavaScript) :x:
Additional context
XState version: 4.13.0
This is a delicate matter - starting from a state is mainly supposed to be used when rehydrating machines. And in this context there are legitimate reasons why a service should not be started - it could, for example, reach its final state before and is not supposed to be activated again.
So is this the intended behaviour?
I was trying to start the machine from a state to be able to test each transition separately, without having to 'play' through all the previous states and conditions, and introducing cross-dependencies.
So is this the intended behaviour?
I can't be 100% sure - that's a question for @davidkpiano . I only have noticed what kind of problems changing the current behavior could cause.
I was trying to start the machine from a state to be able to test each transition separately, without having to 'play' through all the previous states and conditions, and introducing cross-dependencies.
I would say that you absolutely should not unit test machines like this - if you decide to drop XState as a dependency (which hopefully you won't 😉 ) then your tests will need to be refactored completely. Such tests are too tied to the underlying implementation and thus don't give you the desired confidence because you can't really change the code without changing the implementation.
I also ran into this, it seems also delayed transitions (after: { 2000: "AnyState" }
) are not executed when specifying a different state to start with. I'm interested as well whether this is by design.
@tklepzig Can you provide a reproducible example for that?
I'm interested as well whether this is by design.
The best approach moving forward will be to use event sourcing as a pattern for getting the machine back to a certain state. However, this might be a legitimate bug in that machine.resolveState(...)
is not recognizing invocations as actions
to be executed.
@davidkpiano Yep, here it is:
The machine itself in the visualizer: https://xstate.js.org/viz/?gist=24af0af8024dffa5f206d135ae9e85c5
As visualized, the machine loops endlessly between A
, B
and C
every 2 seconds when the initial state is set to A
.
And here the code sandbox: https://codesandbox.io/s/sleepy-neumann-uy4gp?file=/src/index.js
The commented code without the custom initial status works as expected but the call to start("B")
starts with state B
and ends immediately.
I believe I've run into this issue as well. We have a state broken down into multiple invoked sub-machines. If we initialize state to one of the invoked sub-machines we immediately receive an error because the events in each step are typically going to an invoked machine.
I also just run into this. Semantically, I would expect service.start(someState)
to "start" the machine from that state; i.e. run that state's invoke
block. Something like service.set()
would imply the current behavior.
As a workaround, I'm doing service.start(previousState)
and service.send('EVENT')
to cause the machine to be configured in the previous state, transition to the state I actually want, and continue running from there.
The main issue with this is in assuming that a previous invocation should be restarted. In many cases, this should not happen because the invocation might be doing some destructive, non-idempotent side-effect. For example:
addingUser: {
invoke: {
src: 'addUserToDatabase',
onDone: 'added'
},
}
If we start the service at service.start(addingUserState)
(which implies that the machine was previously in the addingUser
state), then the 'addUserToDatabase'
invocation will run twice, which will result in duplicate users.
So this is not an easy problem to solve that satisfies all use-cases. Typically, in serverless workflows like Azure Durable Functions, "activities" (as they call it) are "marked" in some external storage as having been already executed, so that when the entities are replayed to get to their state (again, using an event sourcing approach), the activities are not re-executed.
That makes sense for serverless workflows (which is a valid use-case for XState) but not for client-side, since we might want to re-run the latest invocation/actions (not always, but sometimes). To not differentiate or assume, we should be able to mark invocations/tasks as "retryable", which is not in SCXML, but can be added in userland (or as a helper function from XState):
// TENTATIVE API
addingUser: {
invoke: withRetryPolicy({
src: 'addUserToDatabase',
onDone: 'added'
// specify retry policy (default: null)
}, 'onRestart')
}
For example, an 'onRestart'
retry policy can "decorate" the invocation by inspecting the state
to determine if it is an initial (restored) state, and somehow force XState to restart the invocation.
Still thinking about this; it's part of bigger picture ideas of specifying retry policies for actors.
I'm just going to drop this quickly, since I think I had a similar issue. I haven't read everything, so apologies if it's completely wrong 😬
I'm starting or restarting a state machine with the following code:
const stateMachine = Machine(...)
const initialState = stateMachine.getInitialState(stateString)
stateService.start(initialState)
For me this way everything behaves like it would navigate to this state with events.
See also: https://xstate.js.org/api/classes/statenode.html#getinitialstate
A quick follow up, one problem I encountered with this method, that for this state:
example: {
after: {
2000: {
actions: () => {
console.log('2 seconds passed…')
}
}
}
}
the self-transition (and thus the action) would never run.
Edit: I created a bug issue for this: https://github.com/statelyai/xstate/issues/3011
@davidkpiano What about callback services that listen to parent events? I think they should always be restarted given that they have finished all other code besides the listening part
We'll be working on improved rehydration for the next major version of XState (that is already in the works).
So, collecting all the tips from this thread: if one currently (XState v4) wants to rehydrate a previously persisted state (which was e.g. serialized and stored in LocalStorage under key "state"), but also needs to restart invocations upon rehydration, this is the way:
// appMachine is the result of createMachine()
import { appMachine } from "./definition";
// if we have a persisted state in LocalStorage, we'll use it.
// Otherwise, we'll use the initialState of the machine
const startingStateDefinition = JSON.parse(localStorage.getItem('state')) || appMachine.initialState;
// replace the default context of the machine with the one from (potentially) persisted state
const appMachineWithContext = appMachine.withContext(startingStateDefinition.context);
// set the starting state to the (potentially) persisted state as per @thomasaull suggestion
const startingState = appMachineWithContext.getInitialState(startingStateDefinition.value);
const appService = interpret(appMachineWithContext);
appService.onTransition(newState => {
... // do whatever
})
.start(startingState);
Is this correct, or are there any caveats I'm not thinking about? Thank you!
Stumbled upon this use case on my first xState machine! Waiting for the rehydration feature out of the box! :D
Can confirm @thomasaull 's suggestion runs the actions/invokes on a rehydrated state using .start
❗ Warning: if you use machine.getInitialState(...)
to create a starting state that will run entry actions, entry actions which spawn actors will spawn those actors in an orphaned state, and they will not run.
Since the interpreter is not yet started, it seems like there is no way to spawn them in a non-deferred state, even if it were possible to create them in the context of the interpreter. Also it seems like there's no affordance to start spawned actors after they are created deferred.
@zgotsch This will be resolved in v5, but can you explain your use-case for wanting to grab an initial state from machine.getInitialState(...)
outside of just interpreting the machine?
Hey @davidkpiano, thanks for responding. At Synthesis, we're using Xstate as a central part of our backend. Each group of users playing a game together are tracked by a single XState state machine. This machine is responsible for making teams and allocating gameservers, as well as giving the players reflection prompts and allowing them to vote on their next experience.
After each transition, the machine state is saved into a database. If the server crashes or is replaced by a server with newer code, we rehydrate these machines from the saved state. Because we do a lot of loading using invoke
d promises, we need those invocations to rerun when the machine is rehydrated. (For example, if we invoke
a promise which creates a gameserver, and the machine dies before that promise resolves, we will need to redo that action.) The machine.getInitialState(...)
is our workaround to ensure these invoke
d actions run.
We have some other workarounds to ensure that non-idempotent entry
actions do not run during this rehydration.
I know the current guidance from the docs is to event-source in order to get the machine into the correct state when rehydrating, but at first glance this seems like it would also require some hacking around so that non-idempotent actions do not re-execute. Also, these machines can be quite long-lived, so storing all their actions is a bit of an undertaking.
Additionally, I am currently having some trouble with rehydration of spawned actors and some interactions with entry
actions which expect them to be in place. I might look into alternative ways to ensure the invoke
s rerun during rehydration that don't require the machine.getInitialState(...)
hack.
Happy to chat more about it real-time in discord if you have any questions. I eagerly await more built-out rehydration support in v5!
Get getInitialState()
worked for me too, but it sucks that is not mentioned on the xstate guides
startService() {
this.service
.onTransition((state) => {
this.persistState(state);
// ...
})
.start(this.restoreState());
}
restoreState() {
const stateDefinition = // get it from wherever you persisted it
/**
* use getInitialState instead of recommended State.create
* so that invoke and transition actions are ran again
*
* recommended way: State.create(stateDefinition)
*/
return stateDefinition
? this.machine.getInitialState(stateDefinition.value, stateDefinition.context)
: undefined;
}
Working as expected in xstate@beta
: https://codesandbox.io/s/sad-glade-kcbnx0?file=/src/index.js