xstate icon indicating copy to clipboard operation
xstate copied to clipboard

machine ignores invocation when starting from arbitrary state

Open kristofkalocsai opened this issue 4 years ago • 14 comments

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

Additional context

XState version: 4.13.0

kristofkalocsai avatar Sep 22 '20 12:09 kristofkalocsai

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.

Andarist avatar Sep 22 '20 13:09 Andarist

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.

kristofkalocsai avatar Sep 22 '20 13:09 kristofkalocsai

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.

Andarist avatar Sep 22 '20 16:09 Andarist

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 avatar Oct 04 '20 16:10 tklepzig

@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 avatar Oct 04 '20 17:10 davidkpiano

@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.

tklepzig avatar Oct 04 '20 17:10 tklepzig

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.

codeincarnate avatar Nov 03 '20 05:11 codeincarnate

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.

bobholt avatar Dec 09 '20 13:12 bobholt

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.

davidkpiano avatar Dec 09 '20 14:12 davidkpiano

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

thomasaull avatar Dec 09 '20 16:12 thomasaull

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

thomasaull avatar Feb 03 '22 15:02 thomasaull

@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

ErlanBazarov avatar May 15 '22 06:05 ErlanBazarov

We'll be working on improved rehydration for the next major version of XState (that is already in the works).

Andarist avatar May 15 '22 09:05 Andarist

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!

Arxi avatar Jun 23 '22 12:06 Arxi

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

bombillazo avatar Nov 30 '22 03:11 bombillazo

❗ 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 avatar Dec 23 '22 21:12 zgotsch

@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?

davidkpiano avatar Dec 27 '22 04:12 davidkpiano

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 invoked 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 invoked 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 invokes 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!

zgotsch avatar Dec 28 '22 05:12 zgotsch

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;
}

telmaantunes avatar Jan 12 '23 17:01 telmaantunes

Working as expected in xstate@beta: https://codesandbox.io/s/sad-glade-kcbnx0?file=/src/index.js CleanShot 2023-04-16 at 13 13 01@2x

davidkpiano avatar Apr 16 '23 17:04 davidkpiano