Operations
In the introduction, we saw how to replace async/await
by writing equivalent
code in Effection. We can do this because there are strong analogues between the
way both systems work. However, there are also two key differences that
ultimately make Effection far more robust at handling asynchrony, and in this
section we'll unpack these differences in more detail. In summary: operations
are stateless, and they can be cancelled with grace.
Stateless
The fundamental unit of abstraction for async/await
is the Promise
. They can
be created independently or with an async function, but either way,
Promise
is stateful and will make progress on its own.
If you run the following snippet, it will immediately print Hello World!
to
the console whether we await
the result of sayHello()
or not.
async function sayHello() {
console.log("Hello World!");
};
sayHello();
By contrast, the following will never print anything no matter how long we wait:
function *sayHello() {
console.log("Hello World!");
};
sayHello();
This is because unlike promises, Operations do not do anything by themselves. Instead, they describe what should be done when the operation is run.
Running Operations
In the example above, the generator function contains a recipe that says "log 'Hello World' to the console", but it does not actually execute that recipe itself. We need something else to actually do that.
The simplest way is with the aptly named
run()
function. Here it's used to execute the example above:
import { run } from 'effection';
run(function* sayHello() {
console.log("Hello World!");
});
This will print Hello World!
to the console, just as you'd
expect.
The return value we get from run
is a Task
. A Task
is both an operation
and a promise, so we can use it with await
to integrate Effection into
existing async/await
code.
import { run } from 'effection';
try {
await run(function*() {
throw new Error('oh no!');
});
} catch (error) {
console.error(error);
}
In addition to run()
, there is the main()
function which we
already met in the introduction. It takes care of a few critical
things for you like ensuring that the operation it's running is halted
when the process or browser shuts down, and printing errors to the
console in case anything goes wrong. If you're writing a program with
Effection from scratch, you should most likely use main()
as the
entry point. run()
is more bare-bones, and should only be used when
you need more fine grained control.
Using main()
, our example looks like this:
import { main } from 'effection';
await main(function*() {
throw new Error('oh no!');
});
Instead of needing to catch the error like we did before, main()
will handle it for us, including printing it to the console.
Composing Operations
Entry points like run()
and main()
are used usually once at the very
beginning of an Effection program, but the easiest and most common way to
run an operation is to include it in the body of another operation. To do this,
we use the yield*
keyword, which is the Effection equivalent of await
.
For example, the sleep()
operation pauses execution for a specified duration
before resuming. We can call it from any operation using yield*
;
import { main, sleep } from 'effection';
await main(function*() {
yield* sleep(1000);
console.log("Hello World!");
});
Or, it can be embeded in an operation which itself is embedded in another operation:
import { main, sleep } from 'effection';
function* makeSlow(value) {
yield* sleep(1000);
return value;
}
await main(function*() {
let text = yield* makeSlow('Hello World!');
console.log(text);
});
There is no limit to the number of operations that can be nested within
each other, and in fact, composition is so core to how operations work that
every operation in Effection eventually boils down to a combination of
just three primitive operations: action()
, resource()
, and suspend()
.
Cleanup
Perhaps the most critical difference between promises and operations is that once started, a promise will always run to completion no matter how long that takes. Unfortunately, this makes the guarantees of structured concurrency impossible to provide automatically. Operations, on the other hand, can be interrupted at any time which means that we never have to worry about them staying longer than they are absolutely needed.
Let's consider a hypothetical sleep function implemented using async/await
. It
uses setTimeout()
to resolve a promise once the sleep time has elapsed.
async function sleep(duration) {
await new Promise(resolve => setTimeout(resolve, duration));
}
We can now write our slow hello world program using this version:
async function main() {
await sleep(1000);
console.log("Hello World!");
}
await main();
This logs "Hello World!"
to the console after a delay of 1s, and so it works
as expected when we run it... sort of.
But what happens when you no longer care about the outcome of your sleep operation?
Let's take a hypothetical example of racing two sleep operations against each other. How long do you think this program takes to execute in Node.js?
async function sleep(milliseconds) {
await new Promise(resolve => setTimeout(resolve, milliseconds));
}
await Promise.race([sleep(10), sleep(1000)]);
The anwser is 1000ms even though our intuition tells us that it
should be 10ms. It takes 1000ms because setTimeout
installs a
callback onto the Node run loop, and our process cannot exit until it is
removed.
While our program will move past the await
statement after 10ms, it will not
be allowed to exit for another 990ms until the setTimeout
callback is fired.
💡When something like a run-loop callback outlives its purpose, we call that thing a "leaked effect". Effection was built to take loving care of your effects, and make sure they are never left lying around where someone could step on them and hurt themselves.
By contrast, the equivalent code in Effection will exit immediately after 10ms as expected because every operation can be interrupted at any time.
import { sleep, race, main } from "effection";
await main(function*() {
yield* race([sleep(10), sleep(1000)]);
});
If we look at the implementation of the sleep()
operation in Effection, we can
see how this works. It uses two of the fundamental operations:
action()
, and suspend()
, to wait until the duration has elapsed.
export function sleep(duration) {
return action(function* (resolve) {
let timeoutId = setTimeout(resolve, duration);
try {
yield* suspend();
} finally {
clearTimeout(timeoutId);
}
});
}
However, there is a key difference between this version of sleep()
and the
earlier one we wrote using promises. In this Operation
based implementation,
there is a finally {}
block which clears the timeout callback, and it is
because of this that the Effection sleep()
does not cause the process to hang,
whereas the promise-sleep()
does.
Furthermore, the operation is crystal clear about the order of its execution. The steps are:
- install the timeout callback
- suspend until resolved or discarded.
- uninstall the timeout callback
If we always follow those steps, then we'll always resume at the right point, and we'll never leak a timeout effect.
💡As an alternative to using a
finally {}
block to express your cleanup, you can also use theensure()
operation which in some cases can add clarity. Which method you use is up to you!
Computational Components
It's amazing to think how so many operations in the Effection
ecosystem can be broken down into a combination of the fundamental:
action()
, resource()
, and suspend()
operations. Composability
is the key to everything.
Once you become accustomed to programming with Effection, you'll come to realize that the manner in which operations compose resembles in a large part the way UI components click together in a frontend framework. In a frontend framework, when a parent component is un-rendered, it is understood that it is not the programmer's responsibility to un-render the children, only that they ensure each child is written to take whatever steps necessary to un-render itself.
By the same token, Effection Operations seemlessly bundle setup and teardown together into composable units so that when any operation passes out of scope (or is un-rendered to use the parlance of the UI world) all subordinate operations also pass out of scope. This in turn allows cleanup to happen both automatically and relentlessly; leaving the programmer free to not worry about it.