This guide is for people how have some familiarity with the concepts and goals of the actor model. Whether you've worked with actor systems your whole life, or are looking to try one out for the first time.
This guide is not for people who don't know what the actor model is, or why you would want to use it. If this is you, I'd suggest you head over to the wikipedia article on the actor model (it's uncommonly good for a programming wiki article) and get up to speed, it shouldn't take more than 5 minutes.
$npm install @little-bonsai/ingrates
Ingrates is designed to be used with async generator functions, which aren't supported in some older versions of browsers or nodejs. To check if your given environment can run an async generator you can try evaluating the following code:
async function* Test() {
await new Promise((x) => setTimeout(x, 1));
yield "foo";
await new Promise((x) => setTimeout(x, 1));
return "bar";
}
If that snippet creates errors, you can use babel to compile your code into something that can run in your given environment.
Note: you can use ingrates without async generators, but it's not advised. You can find more information here.
The default export from @little-bonsai/ingrates
is createActorSystem
. This function returns another function, that can be used to start your root actor.
import createActorSystem from "@little-bonsai/ingrates";
const actorSystem = createActorSystem();
function* RootActor() {}
actorSystem(RootActor);
createActorSystem
can take arguments that will be used to configure the system, which we'll go into in more detail later.
Starting a RootActor
on its own isn't much use, to have a system we need more than one actor. We'll use the provided spawn
function to start other actors
function* ChildActor() {}
function* RootActor({ spawn }) {
const childAddress = spawn(ChildActor);
}
The spawn
function takes an actor as input, starts it in the system, and returns an address for that actor.
function* RootActor({ spawn }) {
const childAddress = spawn(ChildActor);
console.log(childAddress);
}
>"GAKe8cxFFyiCN8mePsWc2OuM"
Addresses are how all communication between actors happens. Instead of calling methods on objects you should instead send a message to an address. You can send messages using the provided dispatch
function:
function* RootActor({ spawn, dispatch }) {
const childAddress = spawn(ChildActor);
dispatch(childAddress, { type: "REQUEST_GREETING" });
}
The recieveing actor uses the yield
keyword to recieve its messages
function* ChildActor() {
const message = yield;
console.log(message);
}
>{type: "REQUEST_GREETING", src: "Mw9mMOigEx3Xpu30V6gHq2pG"}
dispatch
doesn't return anything, if an actor wants to recieve a response from a message, they must recieve that response as another message. The src
property is added to all messages so actors know who to send their response to.
function* ChildActor({ dispatch }) {
const message = yield;
const { type, src } = message;
if (type === "REQUEST_GREETING") {
dispatch(src, { type: "GREET" });
}
}
function* RootActor({ spawn, dispatch }) {
const childAddress = spawn(ChildActor);
dispatch(childAddress, { type: "REQUEST_GREETING" });
const message = yield;
console.log(message);
}
>{type: "GREET", src: "GAKe8cxFFyiCN8mePsWc2OuM"}
Actors have a single mailbox that gets filled with messages from all sources. Messages are delivered to actors in the order they are received, one at a time.
Often you will want to place your yield
statement inside some sort of loop, so the actor can process many messages:
function* RunForeverActor() {
while (true) {
const msg = yield;
}
}
function* RunUntilStoppedActor() {
let running = true;
while (running) {
const msg = yield;
if (msg.type === "STOP") {
running = false;
}
}
}
Sometimes you might want to spawn multiple instances of the same actor, with slight differences. Let's use this NamedChildActor
as an example:
function* NamedChildActor(
{ dispatch },
firstName,
lastName,
) {
const msg = yield;
if (msg.type === "REQUEST_GREETING") {
dispatch(msg.src, {
type: "RESPOND_GREETING",
greeting: `Hello from ${firstName} ${lastName}`,
});
}
}
This actor will respond differently depending on what the values of firstName
and lastName
are, and we can set them when we initially spawn
the actor
function* RootActor({ spawn, dispatch }) {
const cain = spawn(NamedChildActor, "Cain", "Smith");
const abel = spawn(NamedChildActor, "Abel", "Smith");
dispatch(cain, { type: "REQUEST_GREETING" });
dispatch(abel, { type: "REQUEST_GREETING" });
while (true) {
const { greeting } = yield;
console.log(greeting);
}
}
>"Hello from Abel Smith"
>"Hello from Cain Smith"
This output looks almost like what we'd expect, but there's one difference: the greetings are printed in a different order than we'd expect
Messages are always handled in the order they are recieved for a specific actor, but ingrates makes no gaurentees on message delivery between actors.
function* ChildActor() {
while (true) {
const msg = yield;
console.log(msg.value);
}
}
function* RootActor({ spawn, dispatch }) {
const child1 = spawn(ChildActor);
const child2 = spawn(ChildActor);
dispatch(child1, { value: "a" });
dispatch(child1, { value: "b" });
dispatch(child2, { value: "c" });
dispatch(child2, { value: "d" });
}
>"a" "b" "c" "d"
>or
>"a" "c" "b" "d"
>or
>"c" "d" "a" "b"
>or
>"a" "c" "d" "b"
Ingrates gaurentees that child1
will handle "a"
before it handles "b"
, and that child2
will handle "c"
before it handles "d"
, but child2
might handle all its messages before child1
is even called once
All actors are passed two known addresses: self
and parent
. The parent
address of the root actor will always be null
.
function* ChildActor({ self, parent }) {
console.log("child", parent, self);
}
function* RootActor({ spawn, self, parent }) {
const child = spawn(ChildActor);
console.log("root", parent, self, child);
}
>"child" "uFsRt4vt4BKP5SekmyUa5Dmw" "Dcqq8RcX0fR8FMHMJmcMgzmL"
>"root" null "uFsRt4vt4BKP5SekmyUa5Dmw" "Dcqq8RcX0fR8FMHMJmcMgzmL"
All the actors we've worked with so far have been sync actors, but if we use async function*
s we gain access to asyncronus funcitonality
async function* NetworkGreeter({ dispatch }) {
while (true) {
const msg = yield;
if (msg.type === "REQUEST_GREETING") {
const networkPayload = await fetch(
"/api/currentUser",
).then((x) => x.json());
dispatch(msg.src, {
type: "GREET",
greeting: `Hello ${networkPayload.username}`,
});
}
}
}
This allows an actor to handle asyncronus functionality, while still only processing one message at a time.
So far we've been looking only at stateless actors: when the program is closed and re-opened, we'll have to recreate all our actors from scratch again. Sometimes we'll want to create actors that can seamlessly restart from their current state if the actor system shuts down. Ingrates provides support for this:
When UserDBActor
first runs, state
will be undefined
, and defaulted to an empty object. As the actor system runs the actor will add more users to the state
variable. Because UserDBActor
yield
s state
, will be persisted outside the actor system to some sort of storage provider. If the actor system shuts down and starts up again UserDBActor
will be respawned, but this time state
will be equal to the last value that was yielded.
function* UserDBActor({ state = {} }) {
console.log("startup", Object.keys(state).length);
while (true) {
const msg = yield state;
if (msg.type === "ADD_USER") {
state[msg.userId] = msg.userDetails;
console.log("ongoing", Object.keys(state).length);
}
}
}
>"startup" 0
>"ongoing" 1
>"ongoing" 2
>"ongoing" 3
>// the user closes their browser, and re-opens it some time later
>"startup" 3
>"ongoing" 4
>"ongoing" 5
>"ongoing" 6
If an actor is stateful, it's important to make sure that every variable that needs to be persisted is stored inside state
. Any variables that are just created inside the actor will be lost on shutdown.
function* UserDBActor({ state = {} }) {
let userCount = 0;
console.log(
"startup",
Object.keys(state).length,
userCount,
);
while (true) {
const msg = yield state;
if (msg.type === "ADD_USER") {
state[msg.userId] = msg.userDetails;
userCount++;
console.log(
"ongoing",
Object.keys(state).length,
userCount,
);
}
}
}
>"startup" 0 0
>"ongoing" 1 1
>"ongoing" 2 2
>"ongoing" 3 3
>// the user closes their browser, and re-opens it some time later
>"startup" 3 0
>"ongoing" 4 1
>"ongoing" 5 2
>"ongoing" 6 3
Because userCount
is defined outside of state
, it is not persisted and re-provided to UserDBActor
when it next starts up.
When an actor system starts up and finds it has persisted actors to restart, it will recreate them With the same address they were originally spawned with. This means that the Parent of any stateful actor must also be stateful and store the addressess of its stateful actors inside it's own state
property.
To show why, let's look at an example of what not to do:
function* UserDBActor({ state = {} }, self) {
console.log(self, "startup", Object.keys(state).length);
while (true) {
const msg = yield state;
if (msg.type === "ADD_USER") {
state[msg.userId] = msg.userDetails;
console.log(
self,
"ongoing",
Object.keys(state).length,
);
}
}
}
function* RootActor({ spawn, dispatch }) {
const userDb = spawn(UserDBActor);
dispatch(userDb, {
type: "ADD_USER",
userId: 1,
userDetails: { name: "Alice" },
});
dispatch(userDb, {
type: "ADD_USER",
userId: 2,
userDetails: { name: "Bob" },
});
dispatch(userDb, {
type: "ADD_USER",
userId: 3,
userDetails: { name: "Clair" },
});
}
>1GsoDGgoRQSepprEYxfUXHxH "startup" 0
>1GsoDGgoRQSepprEYxfUXHxH "ongoing" 1
>1GsoDGgoRQSepprEYxfUXHxH "ongoing" 2
>1GsoDGgoRQSepprEYxfUXHxH "ongoing" 3
>// the user closes their browser, and re-opens it some time later
>1GsoDGgoRQSepprEYxfUXHxH "startup" 3
>WKjcwBqUopez6bDOrvDcvLv1 "startup" 0
>WKjcwBqUopez6bDOrvDcvLv1 "ongoing" 1
>WKjcwBqUopez6bDOrvDcvLv1 "ongoing" 2
>WKjcwBqUopez6bDOrvDcvLv1 "ongoing" 3
In this example, we would expect the restarted RootActor
to send messages to a UserDBActor
that has already recieved them, which would result in no change.
We can see in the output that the actor with address 1GsoDGgoRQSepprEYxfUXHxH
is being restarted, but we're also starting a new actor with address WKjcwBqUopez6bDOrvDcvLv1
that is recieving all the messages from RootActor
.
This is because RootActor
didn't store the address of userDb
in its state, so it doesn't know that there's a restarted instance of UserDBActor
that it can use.
function* RootActor({ spawn, dispatch, state = {} }) {
state.userDb = state.userDb || spawn(UserDBActor);
dispatch(userDb, {
type: "ADD_USER",
userId: 1,
userDetails: { name: "Alice" },
});
dispatch(userDb, {
type: "ADD_USER",
userId: 2,
userDetails: { name: "Bob" },
});
dispatch(userDb, {
type: "ADD_USER",
userId: 3,
userDetails: { name: "Clair" },
});
}
>1GsoDGgoRQSepprEYxfUXHxH "startup" 0
>1GsoDGgoRQSepprEYxfUXHxH "ongoing" 1
>1GsoDGgoRQSepprEYxfUXHxH "ongoing" 2
>1GsoDGgoRQSepprEYxfUXHxH "ongoing" 3
>// the user closes their browser, and re-opens it some time later
>1GsoDGgoRQSepprEYxfUXHxH "startup" 3
>1GsoDGgoRQSepprEYxfUXHxH "ongoing" 3
>1GsoDGgoRQSepprEYxfUXHxH "ongoing" 3
>1GsoDGgoRQSepprEYxfUXHxH "ongoing" 3
By moving userDb
into state, RootActor
will now be restarted with the correct address.
If you try to run the above example, you'll notice that none of the actors are actually being restarted between runs. Ingrates doesn't provide any persisting by default, and you will instead have to use a realizer to tell ingrates how to persist your actors.
There are different realizers available as npm packages which you can find on the ecosystem page
So far we've explored a lot of cool functionality, and you're probably starting to get an idea of how you could build some pretty complex systems using ingrates' actor model. But there always eventually comes a time when the system you're developing has to talk to the outside world. This might mean talking to another different actor system running in another process, or on another machine, or talking to a completly different bit of code like our UI library, or a REST API. We're going to explore how transports can be used to achieve this goal.
This is something we could probably already achieve using the functionality we already know about. Take a look at the following example:
async function* SecretaryActor({ dispatch }) {
while (true) {
const msg = yield;
const [remoteAddress, host] = msg.destination.split(
"@",
);
const reponse = await SomeSocketLibrary.connect(
host,
).send(msg.data);
dispatch(msg.src, response);
}
}
function* RootActor({ spawn, dispatch }) {
const secretary = spawn(SecretaryActor);
dispatch(secretary, {
destination: "abcd123@example.com",
data: {
foo: true,
},
});
const responseFromExampleServer = yield;
}
This is a really cool idea! Using our SecretaryActor
we can send messages to other computers with almost the same syntax, and RootActor
doesn't have to know anything about our network configuration to do it! But, it would be even nicer if we could use exactly the same syntax as usual, and do-away with the SecretaryActor
, like this:
function* RootActor({ spawn, dispatch }) {
dispatch("abcd123@example.com", { foo: true });
const responseFromExampleServer = yield;
}
This is possible if we use a transport in our actor system, to handle messages that need to be delivered to other systems:
function exampleDotComTransport(dispatch) {
SomeSocketLibrary.connect("example.com").on(
"message",
(msg) => dispatch(msg),
);
return ({ msg, snk }) => {
if (snk.endsWith("@example.com")) {
SomeSocketLibrary.connect("example.com").send(msg);
return true;
} else {
return false;
}
};
}
createActorSystem({
transports: [exampleDotComTransport],
})(RootActor);
This guide isn't going to go into much further detail on implementing a transport, you can read more about that in the api docs and look at pre-existing transports in the ecosystem page; but what you need to know is that "transports enable transparent communication across actor system boundaries"
Ingrates comes with quite a lot of functionality built in, but one of ingrates' goals is to be a very small library, so there are some features you might want that aren't included. We'll look at a specific feature that you might want to include, and explore how you could add it to ingrates.
We've already discovered that the dispatch
function doesn't return anything, and that if you want to get a response from an actor, you'll have to recieve a message from them. This is so an actor can continue to process incoming messages while it waits for a response. But there are some times that we don't want to do anything until we've recieved a response; wouldn't it be nice to have a function that worked like dispatch
, but instead returned a Promise
that resolved to our response message?
import fs from "fs";
import { promisify } from "util";
async function* FileActor({ dispatch }, filePath) {
while (true) {
const msg = yield;
if (msg.type === "READ_AS_STRING") {
const fileContents = await promisify(fs.readFile)(
filePath,
"utf8",
);
dispatch(msg.src, { fileContents });
}
}
}
async function* UsernameActor({ query, spawn, dispatch }) {
while (true) {
const msg = yield;
if (msg.type === "REQUEST_USERNAME") {
const file = spawn(FileActor, "/data/username");
const { fileContents } = await query(file, {
type: "READ_AS_STRING",
});
dispatch(msg.src, {
type: "RESPOND_USERNAME",
value: fileContents,
});
}
}
}
query
has the same signature as dispatch
, but it returns a Promise
instead of void
. It also takes an optional 3rd parameter, that defines a timeout:
const { fileContents } = await query(
file,
{ type: "READ_AS_STRING" },
5000,
);
If you try to run the above code, it will fail as query
is undefined
. You'll need to enhance your actor system using an enhancer, which will provide this query
function to all actors that run in the system
const actorSystem = createActorSystem({
enhancers: [queryEnhancer],
});
We're not going to look at how to implement an enhancer here, but you can find more information in the [api][api] section, and you can find a package that implements this queryEnhancer
in the ecosystem section.