xstate icon indicating copy to clipboard operation
xstate copied to clipboard

Bug: Recreating a machine with children after getPersistedSnapshot results in an inability to persist it again

Open iamquoz opened this issue 1 year ago • 7 comments

XState version

XState version 5

Description

Sequence of events:

  1. Create a machine with children (either one or multiple of the same type)
  2. Create an actor and initialize the children machines through events of their parent
  3. Call getPersistsedSnapshot
  4. Try to create new actor from that snapshot
  5. If you reuse the same object, actor works fine and you can call getPersistedSnapshot on a new actor
  6. If you don't reuse it, and instead go through JSON.parse(JSON.serialize(...)), getPersistedSnapshot crashes when calling on a new actor.

Expected result

Restored actors continue to be persistable

Actual result

Restored actors fail to persist their children

Reproduction

https://codesandbox.io/p/devbox/gracious-jerry-njrg8n

Additional context

xstate 5.18.1 (latest)

iamquoz avatar Sep 16 '24 14:09 iamquoz

Have you found a workaround for this @iamquoz ?

Unfortunately this makes spawning actors impossible when there's persistence involved, which seems like a high priority bug to me.

reinoute avatar Jan 20 '25 17:01 reinoute

@reinoute Haven't found a workaround that lets me recreate from a snapshot, however you could save every event your machine recieves and apply them to a blank actor.

iamquoz avatar Jan 20 '25 17:01 iamquoz

@iamquoz I'm using XState in a serverless (stateless) backend, so on each request I need to restore the statemachine from the persisted state. I don't think replaying previous events is realistic for that use case, but I could be wrong...

reinoute avatar Jan 20 '25 18:01 reinoute

I think I have a workaround, but it might be a PITA depending on your scenario: the problem is that the child machine src is the statemachine object, and it doesn't get properly serialized in getPersistedState, so when it rehydrates the sub actor the logic property is just a POJO. if you can inspect your serialized children tree before restoring your parent actor and get all your children state machine definitions, you can patch them in:

(the salient bit is in the loadMachineState function)

import {
    setup,
    createMachine,
    assign,
    spawnChild,
    sendTo,
    StateMachine,
} from "xstate";
import { createActor } from "xstate";
import fs from "fs";
import path from "path";

// Simple file-based persistence
const PERSISTENCE_FILE = path.join(process.cwd(), "machine-state.json");

// Child machine definition
const childMachineDefinition = createMachine({
    id: "childMachine",
    initial: "idle",
    context: {
        count: 0,
    },
    states: {
        idle: {
            on: {
                INCREMENT: {
                    actions: assign({
                        count: ({ context }) => context.count + 1,
                    }),
                },
                NEXT: "active",
            },
        },
        active: {
            on: {
                INCREMENT: {
                    actions: assign({
                        count: ({ context }) => context.count + 1,
                    }),
                },
                BACK: "idle",
            },
        },
    },
});

// Parent machine definition
const parentMachine = setup({
    types: {
        context: {}, // Remove TypeScript type assertion
    },
}).createMachine({
    id: "parentMachine",
    initial: "noChild",
    context: {
        childRef: null,
    },
    states: {
        noChild: {
            on: {
                SPAWN: {
                    target: "hasChild",
                    actions: assign({
                        childRef: ({ spawn }) =>
                            spawn(childMachineDefinition, { id: "child" }) &&
                            false,
                    }),
                },
            },
        },
        hasChild: {
            on: {
                // Forward events to child
                SEND_TO_CHILD: {
                    actions: sendTo("child", ({ event }) => event.childEvent),
                },
                // Stop child
                STOP_CHILD: {
                    target: "noChild",
                    actions: assign({
                        childRef: null,
                    }),
                },
            },
        },
    },
});

// Save machine state to file
function persistMachineState(actor) {
    try {
        // Use getSnapshot instead of getPersistedSnapshot which might not be available
        const snapshot = actor.getPersistedSnapshot();
        console.log("writing snapshot", snapshot);
        fs.writeFileSync(PERSISTENCE_FILE, JSON.stringify(snapshot, null, 2));
        console.log("Machine state persisted successfully");
        return true;
    } catch (error) {
        console.error("Error persisting machine state:", error);
        return false;
    }
}

// Load machine state from file
function loadMachineState() {
    try {
        if (fs.existsSync(PERSISTENCE_FILE)) {
            const data = fs.readFileSync(PERSISTENCE_FILE, "utf8");

            const snap = JSON.parse(data);
            // WORKAROUND - turn src back into the actual child machine definition
            snap.children = snap.children
                ? Object.fromEntries(
                      Object.entries(snap.children).map(([key, child]) => [
                          key,
                          {
                              ...child,
                              src: childMachineDefinition,
                          },
                      ])
                  )
                : snap.children;

            return snap;
        }
        return null;
    } catch (error) {
        console.error("Error loading machine state:", error);
        return null;
    }
}

// Delete persisted state
function clearPersistedState() {
    if (fs.existsSync(PERSISTENCE_FILE)) {
        fs.unlinkSync(PERSISTENCE_FILE);
        console.log("Persisted state cleared");
    }
}

// Demo function
async function runDemo() {
    console.log("\n--- PERSISTENCE DEMO WITH SPAWNED CHILD ---\n");

    // Clear any existing state
    clearPersistedState();

    // STEP 1: Create a new parent machine and spawn a child
    console.log("STEP 1: Creating parent machine and spawning child");
    const parentActor = createActor(parentMachine);
    parentActor.start();

    // Spawn a child
    parentActor.send({ type: "SPAWN" });

    // Send events to the child to change its state
    parentActor.send({
        type: "SEND_TO_CHILD",
        childEvent: { type: "INCREMENT" },
    });

    parentActor.send({
        type: "SEND_TO_CHILD",
        childEvent: { type: "NEXT" },
    });

    parentActor.send({
        type: "SEND_TO_CHILD",
        childEvent: { type: "INCREMENT" },
    });

    // Log the current state
    const snapshot1 = parentActor.getSnapshot();
    console.log("Parent state:", snapshot1.value);
    console.log("Child exists:", !!snapshot1.children.child);

    if (snapshot1.children.child) {
        const childSnapshot = snapshot1.children.child.getSnapshot();
        console.log("Child state:", childSnapshot.value);
        console.log("Child count:", childSnapshot.context.count);
    }

    // STEP 2: Persist the machine state
    console.log("\nSTEP 2: Persisting machine state");
    const persisted = persistMachineState(parentActor);
    if (!persisted) {
        console.error("Failed to persist state, aborting demo");
        return;
    }

    // Stop the actor
    parentActor.stop();
    console.log("Original actor stopped");

    // STEP 3: Restore from persistence
    console.log("\nSTEP 3: Restoring machine from persistence");
    const persistedState = loadMachineState();

    if (!persistedState) {
        console.error("Failed to load persisted state, aborting demo");
        return;
    }

    // Create a new actor with the persisted state
    const restoredActor = createActor(parentMachine, {
        snapshot: persistedState,
    });

    restoredActor.start();

    // Log the restored state
    const snapshot2 = restoredActor.getSnapshot();
    console.log("Restored parent state:", snapshot2.value);
    console.log("Restored child exists:", !!snapshot2.children.child);

    if (snapshot2.children.child) {
        const childSnapshot = snapshot2.children.child.getSnapshot();
        console.log("Restored child state:", childSnapshot.value);
        console.log("Restored child count:", childSnapshot.context.count);
    } else {
        console.error("Child was not restored properly!");
    }

    // STEP 4: Verify we can interact with the restored child
    console.log("\nSTEP 4: Interacting with restored child");

    if (snapshot2.children.child) {
        // Send more events to the child
        restoredActor.send({
            type: "SEND_TO_CHILD",
            childEvent: { type: "INCREMENT" },
        });

        // Log the updated state
        const snapshot3 = restoredActor.getSnapshot();
        const childSnapshot = snapshot3.children.child.getSnapshot();
        console.log("Child state after increment:", childSnapshot.value);
        console.log(
            "Child count after increment:",
            childSnapshot.context.count
        );

        // STEP 5: Re-persist the state with the updated child
        console.log("\nSTEP 5: Re-persisting state with updated child");
        const rePersisted = persistMachineState(restoredActor);

        if (rePersisted) {
            console.log("Successfully re-persisted state with child");
        } else {
            console.log("Failed to re-persist state with child");
            throw new Error("FAILED");
        }
    } else {
        console.error("Cannot interact with child as it was not restored");
    }

    // STEP 6: Restore again from the re-persisted state
    console.log("\nSTEP 6: Restoring from re-persisted state");
    const rePersistState = loadMachineState();

    if (!rePersistState) {
        console.error("Failed to load re-persisted state");
        return;
    }

    const finalActor = createActor(parentMachine, {
        snapshot: rePersistState,
    });

    finalActor.start();

    // Log the final restored state
    const finalSnapshot = finalActor.getSnapshot();
    console.log("Final parent state:", finalSnapshot.value);
    console.log("Final child exists:", !!finalSnapshot.children.child);

    if (finalSnapshot.children.child) {
        const childSnapshot = finalSnapshot.children.child.getSnapshot();
        console.log("Final child state:", childSnapshot.value);
        console.log("Final child count:", childSnapshot.context.count);
        console.log(
            "\nDEMO SUCCESSFUL: Child was properly persisted and restored twice"
        );
    } else {
        console.error(
            "\nDEMO FAILED: Child was not properly restored in the final step"
        );
    }

    // Clean up
    finalActor.stop();
    clearPersistedState();
}

// Run the demo
runDemo().catch((error) => {
    console.error("Demo failed with error:", error);
});

rynomad avatar Apr 02 '25 21:04 rynomad

I am facing the exact same issue.

Here's my minimal reproduction: (I am using Deno)

import { assign, createActor, fromCallback, setup } from "npm:xstate@5";

const myMachine = setup({
  actions: {
    logTaskAEntry: () => console.log("Entering processing.taskA"),
    logTaskBEntry: () => console.log("Entering processing.taskB"),
  },
  actors: {
    cb: fromCallback(({ input: { task, user } }) => {
      console.log("CALLBACK FOR task and user", task, user);
      return () => {};
    }),
  },
}).createMachine(
  {
    id: "myMachine",
    initial: "idle",
    context: ({ input }) => ({
      count: input.count,
      user: input.user,
      nested: null,
    }),
    states: {
      idle: {
        on: { START: "processing.taskA" },
      },
      processing: {
        initial: "taskA",
        invoke: {
          src: "cb",
          input: ({ context }) => ({ task: "processing", user: context.user }),
        },
        states: {
          taskA: {
            entry: [() => console.log("test taskA"), { type: "logTaskAEntry" }],
            on: { NEXT: "taskB" },
          },
          taskB: {
            entry: [
              { type: "logTaskBEntry" },
              assign({
                nested: ({ spawn }) =>
                  spawn(myMachine, {
                    input: {
                      count: 1,
                      user: { id: "user3", name: "Jerome" },
                    },
                  }),
              }),
              ({ context }) => context.nested.send({ type: "START" }),
            ],
            on: { FINISH: "#myMachine.finished" },
          },
        },
      },
      finished: {
        type: "final",
      },
    },
  },
);

const actor = createActor(myMachine, {
  input: {
    count: 1,
    user: { id: "user2", name: "Bob" },
  },
});

// Subscribe to state changes (optional, for demonstration)
actor.subscribe((snapshot) => {
  console.log("[Actor 1] Current state:", snapshot.value);
  // console.log('(2)Current context:', snapshot.context);
});

actor.start();

actor.send({ type: "START" });
actor.send({ type: "NEXT" });

Deno.writeFileSync(
  "state-test.json",
  new TextEncoder().encode(JSON.stringify(actor.getPersistedSnapshot())),
);

console.log("wrote snapshot");

const snapshot = JSON.parse(
  new TextDecoder().decode(Deno.readFileSync("state-test.json")),
);

console.log("-------------------");

// Create the actor, providing your custom initial state configuration
const actor2 = createActor(myMachine, {
  snapshot, //: actor.getPersistedSnapshot()
});

// Subscribe to state changes (optional, for demonstration)
actor2.subscribe((snapshot) => {
  console.log("[Actor 2] Current state:", snapshot.value);
  // console.log('Current context:', snapshot.context);
});

// Start the actor
actor2.start();

I get the logs:

❯ deno -A snap-test.js
[Actor 1] Current state: idle
test taskA
Entering processing.taskA
CALLBACK FOR task and user processing { id: "user2", name: "Bob" }
[Actor 1] Current state: { processing: "taskA" }
Entering processing.taskB
test taskA
Entering processing.taskA
CALLBACK FOR task and user processing { id: "user3", name: "Jerome" }
[Actor 1] Current state: { processing: "taskB" }
wrote snapshot
-------------------
CALLBACK FOR task and user processing { id: "user2", name: "Bob" }
[Actor 2] Current state: { processing: "taskB" }

I am missing CALLBACK FOR task and user processing { id: "user3", name: "Jerome" }

If I use the non-serialized snapshot, I get the correct logs:

❯ deno -A snap-test.js
[Actor 1] Current state: idle
test taskA
Entering processing.taskA
CALLBACK FOR task and user processing { id: "user2", name: "Bob" }
[Actor 1] Current state: { processing: "taskA" }
Entering processing.taskB
test taskA
Entering processing.taskA
CALLBACK FOR task and user processing { id: "user3", name: "Jerome" }
[Actor 1] Current state: { processing: "taskB" }
wrote snapshot
-------------------
CALLBACK FOR task and user processing { id: "user2", name: "Bob" }
CALLBACK FOR task and user processing { id: "user3", name: "Jerome" }
[Actor 2] Current state: { processing: "taskB" }

jeromegn avatar May 13 '25 15:05 jeromegn

Is there an update to this? I thought https://github.com/statelyai/xstate/pull/5269 would fix this problem, but it doesn't seem so as of v5.20

LuccaPassos avatar Jun 26 '25 00:06 LuccaPassos

@LuccaPassos it appears to have solved it for me. I am on 5.20.1

jeromegn avatar Jul 13 '25 18:07 jeromegn