Skip to content

Instantly share code, notes, and snippets.

@Brian-McBride
Last active June 9, 2023 20:23
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Brian-McBride/8893d4a22a60b040d531ee62366f6002 to your computer and use it in GitHub Desktop.
Save Brian-McBride/8893d4a22a60b040d531ee62366f6002 to your computer and use it in GitHub Desktop.
XState Actor Manager

Actor Manager

The goal here is to be able to dynamically add and spawn actors within xstate.

Items of Note

  • The id provdied will be the one that resolved on the system object for system.get(id) calls from other actors.
  • There isn't a lot of garbage colleciton or the concept of removing actors yet
  • This probably works best with other full state machines
  • Sending a message to a machine not spawed isn't captured or buffered in any way. The event errors or is lost.

Why?

I have a complex app, but I don't want to load up an entire engine on startup. Time-to-interactive and all that.

I could keep all the engines apart and "glue" them together inside components. But that puts more logic in views.

This is an example flow:

  1. App starts
  2. Auth login check
  3. Auth passes - user is logged in
  4. Fetch user data
  5. Based on user data, route app
  6. Start app engine
  7. App triggers more complex functionality
  8. Route to complex component
  9. Start complex engine
  10. ...

The idea here is that we still want the greater engine to be available. I might have a messaging engine for user to user communication. I don't need that engine immediately, but later another part of my app engine will event to it.

This currently doesn't solve for events sent prior to a spawn. I suppose more robust event queue could be added to optionally buffer events until the actor spawns and is available.

https://stately.ai/registry/editor/506263db-32f9-49ce-a134-f9cd8c2aa970?machineId=21b90cf1-c15c-4fa6-b4e4-950fb3dda197&mode=Design

import {
assign,
createMachine,
pure,
raise,
type ActorBehavior,
type AnyActorRef,
type AnyEventObject,
type AnyStateMachine,
type BaseActionObject,
type MachineContext,
} from 'xstate';
import { z } from 'zod';
const ActorEventAdd = z.object({
type: z.literal('actor.add'),
params: z.object({
actor: z.any(),
id: z.string(),
systemId: z.string().optional(),
autoStart: z.boolean().optional(),
input: z.any().optional(),
}),
});
export type ActorEventAdd = z.infer<typeof ActorEventAdd>;
const ActorEventStart = z.object({
type: z.literal('actor.start'),
params: z.object({
id: z.string(),
}),
});
export type ActorEventStart = z.infer<typeof ActorEventStart>;
const ActorEventStop = z.object({
type: z.literal('actor.stop'),
params: z.object({
id: z.string(),
}),
});
export type ActorEventStop = z.infer<typeof ActorEventStop>;
const ActorEventStarted = z.object({
type: z.literal('actorManager.started'),
params: z.object({
id: z.string(),
}),
});
export type ActorEventStarted = z.infer<typeof ActorEventStarted>;
const ActorEventStopped = z.object({
type: z.literal('actorManager.stopped'),
params: z.object({
id: z.string(),
}),
});
export type ActorEventStopped = z.infer<typeof ActorEventStopped>;
const ActorProcessEvents = z.object({
type: z.literal('actorManager.processEvents'),
});
export type ActorProcessEvents = z.infer<typeof ActorProcessEvents>;
const ActorEventsProcessed = z.object({
type: z.literal('actorManager.eventsProcessed'),
});
export type ActorEventsProcessed = z.infer<typeof ActorEventsProcessed>;
export interface ActorManagerActor {
id: string;
systemId: string;
src: AnyStateMachine | ActorBehavior<AnyEventObject>;
actorRef: AnyActorRef;
input?: unknown;
}
export interface ActorManagerContext extends MachineContext {
actors: ActorManagerActor[];
events: AnyEventObject[];
isWorking: boolean;
currentEvent: AnyEventObject | null;
}
export const actorManagerMachine = createMachine(
{
id: 'Actor Manager',
context: {
actors: [],
events: [],
isWorking: false,
currentEvent: null,
},
initial: 'ACTORS IDLE',
states: {
'ACTORS IDLE': {
entry: ['setNotWorking'],
always: [
{
target: 'ACTORS PROCESSING',
guard: 'hasEvents',
},
],
on: {
'actorManager.processEvents': {
target: 'ACTORS PROCESSING',
guard: 'hasEvents',
},
},
},
'ACTORS PROCESSING': {
entry: ['setWorking', 'eventStackWorker'],
always: [
{
target: 'ACTORS STARTING',
guard: 'currentIsStart',
},
{
target: 'ACTORS STOPPING',
guard: 'currentIsStop',
},
],
on: {
'actorManager.processEvents': {
target: 'ACTORS PROCESSING',
reenter: true,
},
'actorManager.eventsProcessed': {
target: 'ACTORS IDLE',
},
},
},
'ACTORS STARTING': {
entry: ['startActor'],
on: {
'actorManager.processEvents': {
target: 'ACTORS PROCESSING',
},
},
},
'ACTORS STOPPING': {
entry: ['stopActor'],
on: {
'actorManager.processEvents': {
target: 'ACTORS PROCESSING',
},
},
},
},
on: {
'actor.add': {
actions: ['addActor'],
},
'actor.*': {
actions: ['addEvent', 'startProcess'],
},
},
types: {
context: {} as ActorManagerContext,
events: {} as
| ActorEventAdd
| ActorEventStart
| ActorEventStop
| ActorProcessEvents
| ActorEventsProcessed
| { type: 'actor.*'; params: unknown },
},
},
{
guards: {
hasEvents: ({ context }) => context.events.length > 0,
currentIsStart: ({ context }) =>
context.currentEvent?.type === 'actor.start',
currentIsStop: ({ context }) =>
context.currentEvent?.type === 'actor.stop',
},
actions: {
startProcess: ({ self }) =>
self.send({ type: 'actorManager.processEvents' }),
addEvent: assign({
events: ({ context, event }) => {
const parsed = z
.union([ActorEventAdd, ActorEventStart, ActorEventStop])
.safeParse(event);
if (!parsed.success) {
if (process.env.NODE_ENV === 'development') {
console.error(
'Event is not a valid ActorEventAdd, ActorEventStart, or ActorEventStop',
parsed.error
);
}
return context.events;
}
return [...context.events, event];
},
}),
addActor: pure(({ event }) => {
const parsed = ActorEventAdd.safeParse(event);
if (!parsed.success) {
if (process.env.NODE_ENV === 'development') {
console.error('Event is not a valid ActorEventAdd', parsed.error);
}
return [];
}
const finalActions: BaseActionObject[] = [];
finalActions.push(
assign({
actors: ({ context }) => {
const { actor, id, systemId, input } = parsed.data.params;
const newActor = {
id,
systemId: systemId || id,
src: actor,
actorRef: null,
input,
};
return [...context.actors, newActor];
},
})
);
if (parsed.data.params.autoStart) {
raise({
type: 'actor.start',
params: { id: parsed.data.params.id },
} as never);
}
return finalActions;
}),
startActor: pure(({ context }) => {
const actorId = context.currentEvent?.params.id;
const actorData = context.actors.find(
(a) => actorId && a.id === actorId
);
if (!actorData || actorData.actorRef !== null) {
if (process.env.NODE_ENV === 'development') {
console.warn(
!actorData
? `Actor with id ${actorId} not found in actor manager`
: `Actor with id ${actorId} has previously been started`
);
}
return [raise({ type: 'actorManager.processEvents' })];
}
return [
assign({
actors: ({ context, spawn }) =>
context.actors.map((a: ActorManagerActor) =>
a.id === actorId
? {
...a,
actorRef: spawn(a.src, { id: a.id, input: a.input }),
input: undefined, // Clear for GC
}
: a
),
}),
raise({ type: 'actorManager.processEvents' }),
];
}),
stopActor: pure(({ context }) => {
const actorId = context.currentEvent?.params.id;
const actorData = context.actors.find(
(a) => actorId && a.id === actorId
);
if (!actorData || actorData.actorRef === null) {
if (process.env.NODE_ENV === 'development') {
console.warn(
!actorData
? `Actor with id ${actorId} not found in actor manager`
: `Actor with id ${actorId} is not running`
);
}
return [raise({ type: 'actorManager.processEvents' })];
}
return [
assign({
actors: ({ context }) =>
context.actors.map((a: ActorManagerActor) => {
if (a.id === actorId) {
a.actorRef.stop();
return {
...a,
actorRef: null,
};
}
return a;
}),
}),
raise({ type: 'actorManager.processEvents' }),
];
}),
eventStackWorker: pure(({ context }) => {
if (context.currentEvent) {
return []; // Already processing an event
}
const events = context.events || [];
if (events.length === 0) {
return [raise({ type: 'actorManager.eventsProcessed' })];
}
return [
assign({
currentEvent: () => events[0],
events: () => events.slice(1),
}),
];
}),
setWorking: assign({
isWorking: () => true,
}),
setNotWorking: assign({
isWorking: () => false,
}),
},
}
);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment