Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Usage with async/interpreter #1

Open
Greg-Hitchon opened this issue Apr 28, 2021 · 4 comments
Open

Usage with async/interpreter #1

Greg-Hitchon opened this issue Apr 28, 2021 · 4 comments

Comments

@Greg-Hitchon
Copy link

Hey @davidkpiano,

New to this library (xstate) and relatively new to Durable Entities (but big fan of both!). Wondering the recommended way of running an interpreter with Durable Entities that has some async actions. Specifically:

  1. When entering/exiting a state invoke an external api call which should return ok/error
  2. Possibly update context/complete an automatic transition
  3. Persist state to entity

This question is related to this issue. As I currently understand there are 2 options:

  1. Create a service and add a listener to be notified of changes
  2. "Poll" the service until state is settled

Not sure how/if option #1 would work, however option #2 "works" at least with this simple case (shown below). Just wondering if there is a better/advised pattern for this?

export default entity(async (context) => {
  const currentValue = context.df.getState(
    () => donutMachine.initialState
  ) as typeof donutMachine.initialState;

  switch (context.df.operationName) {
    case 'send':
      const eventObject = context.df.getInput() as EventObject;
      const state = donutMachine.resolveState(State.create(currentValue));

      const service = interpret(donutMachine);
      service.start(state);
      service.send(eventObject)

      // Additional "hack" to wait for state to settle
      do {
        await new Promise((r) => setTimeout(r, 1000));
      } while (Object.entries(service.state.activities).some((v) => v[1]));

      context.df.setState(service.state);
      service.stop();
      break;
    default:
      break;
  }
});

To paste into xstate viz (slightly modified to add async invoke + back):

function invokeTest(context) {
  console.log("invoking 1");
  return new Promise(resolve => {
    setTimeout(resolve, 1000);
  });
}

const donutMachine = Machine({
  id: 'donut',
  initial: 'ingredients',
  states: {
    ingredients: {
      on: {
        NEXT: 'directions',
      },
    },
    directions: {
      initial: 'makeDough',
      onDone: 'fry',
      states: {
        makeDough: {
          on: { NEXT: 'mix' },
        },
        mix: {
          type: 'parallel',
          states: {
            mixDry: {
              initial: 'mixing',
              states: {
                mixing: {
                  on: { MIXED_DRY: 'mixed' },
                },
                mixed: {
                  type: 'final',
                },
              },
            },
            mixWet: {
              initial: 'mixing',
              states: {
                mixing: {
                  on: { MIXED_WET: 'mixed' },
                },
                mixed: {
                  type: 'final',
                },
              },
            },
          },
          onDone: 'allMixed',
        },
        allMixed: {
          type: 'final',
        },
      },
    },
    fry: {
      initial: "loading",
      states: {
        loading: {
          invoke: {
            id: "test-call",
            src: invokeTest,
            onDone: 'done'
          }
        },
        done: {
          type: 'final'
        }
      },
      onDone: 'flip'
    },
    flip: {
      on: {
        NEXT: 'dry',
        BACK: 'fry'
      },
    },
    dry: {
      on: {
        NEXT: 'glaze',
      },
    },
    glaze: {
      on: {
        NEXT: 'serve',
      },
    },
    serve: {
      on: {
        ANOTHER_DONUT: 'ingredients',
      },
    },
  },
});
@davidkpiano
Copy link
Owner

Great insights - it would be nice to use DE with an interpreter to ensure that actions get executed.

I'm thinking it might be simpler than waiting for a promise, though. Currently, the service.send() method is synchronous, and the next state is "settled" immediately, but it should be treated as asynchronous (or more accurately, a () => void function). This is because in statecharts, transitions are always zero-time.

So what might make more sense is to have some sort of synchronous interpreter that both returns the next state and actions. This doesn't need to be as complex as the current interpreter, since it's just a matter of doing two things:

  1. Returning the next state based on current state and event
  2. Executing actions

So it can look like this:

function execTransition(machine, state, event) {
  const nextState = machine.transition(state, event);

  nextState.actions.forEach(action => {
    // execute each action
  });

  return nextState;
}

I'll think about this more. It's definitely an important use-case for serverless workflows.

@Greg-Hitchon
Copy link
Author

Thanks for the response! My understanding then is:

  1. Actions are side effects and as long as they are called we are happy. In which case a simple custom interpreter could be used
  2. Invoking services (like the example "invoking promises") however create a situation where the state is not synchronous.

For a concrete example using the "invoking promises" code (below). We want to:

  1. Signal our entity to "FETCH"
  2. OPTION A (synchronous): update entity state immediately (but is not "settled" state, such that our entity state would not be in the correct "success" or "failure" state, and when we try to rehydrate from that state we will be stuck)
  3. OPTION B (asynchronous): wait until state has "settled" then update entity state

It makes sense to me to "wait" in this case (OPTION B) as long as the invoked services are guaranteed to resolve, however it seems a little questionable.

// Function that returns a promise
// This promise might resolve with, e.g.,
// { name: 'David', location: 'Florida' }
const fetchUser = (userId) =>
  fetch(`url/to/user/${userId}`).then((response) => response.json());

const userMachine = Machine({
  id: 'user',
  initial: 'idle',
  context: {
    userId: 42,
    user: undefined,
    error: undefined
  },
  states: {
    idle: {
      on: {
        FETCH: 'loading'
      }
    },
    loading: {
      invoke: {
        id: 'getUser',
        src: (context, event) => fetchUser(context.userId),
        onDone: {
          target: 'success',
          actions: assign({ user: (context, event) => event.data })
        },
        onError: {
          target: 'failure',
          actions: assign({ error: (context, event) => event.data })
        }
      }
    },
    success: {},
    failure: {
      on: {
        RETRY: 'loading'
      }
    }
  }
});

@davidkpiano
Copy link
Owner

Ah, so you basically want something like Promise.allSettled(...)?

@Greg-Hitchon
Copy link
Author

Yeah I think so! But very new to xstate so just kind of feeling out how this would work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants