Replies: 4 comments 11 replies
-
Love the thought that was put into this! I am also eager to see an idiom built around solving this problem. My current solution is to wrap all of my disparate actors at the top level of a "Parent Machine." Each of those actors gets its own hook, so that my UI can use An explicit bus/registry for message passing would be a Godsend! |
Beta Was this translation helpful? Give feedback.
-
Alright, I've been playing around with these ideas over the last month or so and think I've come to a pretty good solution. Everything presented can be bikeshedded for the right API to align with XState/SCXML. A full example repo can be found at xstate-choreography and the file of most interest here is choreographer.machine.js. The example repo is successfully passing messages between multiple actors and hierarchies (and a service worker with @ChrisShank's web worker invoke) with nothing more than a reference ID. Turns out the web worker stuff didn't really need a separate protocol/functionality because the invoke worker solves that problem so elegantly. Step 1: Actor Registration / SpawningWhen defining machines, we need to specify your machine's ID as an exportable const identifier: export const browserStatusMachineId = 'browserStatus'
export const browserStatusMachineDef = createMachine({
id: browserStatusMachineId,
}) This is used as the "target" when passing events around within the choreographer. Now, because machines/actors don't currently have access to their send(
{
type: 'SPAWN_GLOBAL_ACTOR',
data: {
def: browserStatusMachineDef,
id: browserStatusMachineId,
},
},
{ to: choreographerMachine },
), Currently, I've setup Choreo to spawn the actor and then pass a reference back to the parent machine, but this is one of those bikesheddable things as to where responsibility should lie (ie. should the parent spawn and then send the ref?). This sets up a new actor in the context that looks like this: {
actors: {
browserStatus: {
subscribers: [],
ref: Ref<> // spawned machine
}
}
} The web workers registration works similarly: // register a worker from a parent machine
send(
{
type: 'REGISTER_SERVER_WORKER',
data: {
workerId: fetchServiceWorkerId, // the invoked worker's id on the parent machine
},
},
{ to: choreographerMachine },
) The only main difference between workers and actors is that, because the workers are an invoked service rather than an actor, we have to coordinate through the parent machine a little bit more: // choreographer -> store service worker ref + origin
storeServerWorkerRef: assign({
workers: ({ workers }, { data }, { _event }) => {
const { origin } = _event
const { workerId } = data
return {
...workers,
[workerId]: {
ref: origin,
subscribers: [],
},
}
},
}) This would create a new worker reference in Choreographer's context that looks like this: {
workers: {
fetchServiceWorker: {
subscribers: [],
ref: 'x:3' // xstate's internal reference to the worker's parent machine
}
}
} And then the origin (worker's parent machine) needs to be able to forward on events to the worker: forwardToWorker: send((_ctx, { payload }) => ({ ...payload }), {
to: (_ctx, { workerId }) => workerId,
}), Step 2: Sending EventsOnce our actors/service workers are registered with choreographer, then we can start sending events around without needing to worry about passing refs. For example, to send a message from one machine to another that is registered with Choreo, it becomes rather trivial: import { displayMachineId } from './display.machine'
// inside machine's actions
sendDisplayActor: send(
(ctx) => ({
type: 'SEND_TO_ACTOR',
actorId: displayMachineId,
payload: {
type: 'UPDATED_BROWSER_STATUS',
data: { ...ctx },
},
}),
{ to: choreographerMachine },
), Or to a service worker: sendServiceWorker: send(
(ctx) => ({
type: 'SEND_SERVICE_WORKER',
workerId: fetchServiceWorkerId,
payload: {
type: 'UPDATED_BROWSER_STATUS',
data: { ...ctx },
},
}),
{ to: choreographerMachine },
) Fundamentally, wherever you see an event with a 3. SubscribersIf you noticed above, both of our registration sample objects had a send(
{
type: 'SUBSCRIBE_TO_ACTOR',
data: { actorId: browserStatusMachineId },
},
{ to: choreographerMachine },
) And then a machine with subscribers can emit events: sendSubscribers: send(
(ctx) => ({
type: 'NOTIFY_SUBSCRIBERS',
publisherId: browserStatusMachineId,
payload: {
type: 'UPDATED_BROWSER_STATUS',
data: { ...ctx },
},
}),
{ to: choreographerMachine },
), Ultimately, I would probably look at streamlining some of these events with something similar to XState's import { sendActor, sendWorker, subscribeActor, notifySubscribers } from '@xstate/choreographer` 4. Component UsageBut here's the best of all. As you can see in this commit, by using the <script>
import { getActor } from '../machines/choreographer.machine'
import { useActor } from '@xstate/vue'
import { displayMachineId } from '../machines/display.machine'
export default {
setup() {
const { state } = useActor(getActor(displayMachineId))
return {
state,
}
},
}
</script> |
Beta Was this translation helpful? Give feedback.
-
I thought a bit about registries and event buses for bigger actor architectures. Today I decided to focus more on an event bus. My main criteria for an event bus would be that it can be visualized in the sequence diagram inside the inspector. This can help with understanding the communication flow between the actors. A while back @edgerunner posted a link to his invoked service based implementation on the Stately discord. Trying it out reveals that an invoked service based bus is indeed visualized in the sequence diagram, which looks quite nice. However, the visualization is only really useful if every consumer gives the bus the same id. Different ids display different buses, although the "same" bus is used. Other than that, I think the visualization could further be improved if the events send to the bus are also visualized, not only the events send by the bus. What I like about this implementation is its simplicity. Using a invoked service as an event bus, simplifies mocking the bus for consuming machines during testing a lot. For this implementation, the visualizer draws events from the bus to every consuming actor, even if they do not care about the event. This is technically correct but adds clutter if a lot of consumers exits, which decreases readability. I am wondering whether a advanced bus that allows consumers to subscribe to specific events could solve this. At the same time, such implementation would make the bus more complicated and it might no longer be feasible to implement such bus based on an invoked callback. Hope this sandbox helps anyone. The event bus typing can properly be improved. Would love to hear your thoughts. |
Beta Was this translation helpful? Give feedback.
-
Hi. This is a great idea. I just have two questions about cleaning up unneeded actors since they are spawned within the context of the choreographer as their parent. How do we allow the garbage collector to clean them? Also, how do you suggest to query the current state of an actor/worker? Since we only communicate through messages/events, are you thinking to have a specific message where the actor reply with its full state? I was thinking to extend the current actor implementation by exposing the internal subscription mechanism that keeps the actor reference in sync with its actual machine to allow for some kind of a remote subscription with configurable communication protocol. Now the actor can reside anywhere on another thread, process, or even over the network. What do you think? |
Beta Was this translation helpful? Give feedback.
-
There have been a number of ongoing discussions in the Discord channel recently about finding a way to build a "transport layer" between various state machines/actors within XState. There's essentially two different, but related aspects to this problem that have surfaced in initial discussions:
1. Reference Passing + Brittle Dependency Trees
In the app I'm currently in the process of re-writing in XState, we have annotation objects which can be modified in about a dozen ways from 2-3 different areas of the main app screen. So I have a single annotation machine that spawns as an actor for each annotation object. However, because there are so many different areas of the app that need to communicate with those annotation actors, I'm finding myself writing a lot of boilerplate where I'm either passing those annotation references on initialization or having to depend on multiple events to get the annotation refs to the deeply nested children that need them (ie. parent > child actor > grandchild actor > great-grandchild actor).
This is what in the UI world they would call prop drilling. It's generally unfavourable for applications of any decent size for a few reasons:
toggle
when it starts in the event chain becomesisToggled
further down the event chain where it is evaluated making it confusing for developers without following the whole chain)React solved this problem with the Context API. Vue has solved this problem in Vue3 with the Provide/Inject pattern.
Context/Provide are both good ways of solving this problem when you can make the assumption safely that you have a strictly hierarchical relationship between some sort of "root" and your deeply-nested descendants. As long as the context you need to provide can be initialized far enough up the chain to be accessible to all of the descendants that require it, then there isn't really an issue. That, however, brings us to discussions of the second problem space.
Multi-threaded State Machines, Service Workers and Asynchronous Lifecycles
Browsers have gotten really good over the years of being able to update, manage, and paint the DOM. However, because JavaScript in the browser is single-threaded by default, as apps have gotten more complex, we needed to introduce complexity like the virtual DOM as a control mechanism to ensure browsers were only needing to re-paint the necessary pieces. Modern app requirements like offline usage, periodic background syncs, or push notifications all add weight and complexity to a single-threaded application that might not even be used much during the main lifecycle of the app in their day-to-day usage.
And this is why, over the last decade, we've seen the rise and utilization of service workers as a means to move functionality that doesn't require a web page or user interaction into the background (modifying/caching requests, saving to LocalStorage or IndexedDB, etc.). Service workers can't access the DOM directly, so they communicate with the main browser thread/DOM by sending messages back and forth.
This means a few things for this discussion:
3. Summary
I can't guarantee, for example, what features in the future (or even thread for that matter) are going to need to reach into my annotation machines to read or update their state, so there should be a way for me to avoid creating those dependency trees from the beginning or needing to refactor in the future to gain access to that state.
Thankfully, this is an old problem that software engineers have been dealing with for a while. The most recent take on this would be a microservice architecture, the frontend world would call it a micro frontend, and the networking world would call it a distributed system:
If we swap "computers" for State Machines in the above quotes then, for the most part, we already have just about everything we need in XState to build a distributed system. The piece that I think we're missing in the XState ecosystem right now would be the "aggregation layer" or what we might call choreography for XState:
Adding a sort of "Event Broker" to the XState ecosystem means that we can build our state machines in a distributed way, whether they be on the main thread or off. Our UI, rather than needing to have context or references passed down the chain to the actor they're actually subscribed to, can simply pull the reference from the Composer. Our other state machines in the main thread won't care if the actor they need to communicate with is in the main thread, or in their ancestry tree, or in a service worker, all they care about is being able to send/receive messages from the Event Broker.
Fundamentally, this wouldn't be too hard to already accomplish with XState by just having a global
window.Composer
machine that has a registration/deregistration system for actors and just forwards messages automatically. Where it gets difficult, and why I ultimately feel like this belongs as a separate package as part of the main XState ecosystem is that the brokering between the main browser and service workers requires a fundamentally different messaging protocol and registration system than the normal XState event system and means underlying complexity mapping to/from those message events.Look forward to hearing everyone's thoughts.
Beta Was this translation helpful? Give feedback.
All reactions