Scope
We have talked about how Effection operations allow you to bundle setup and teardown as a unit so that automatic cleanup is guaranteed, but how are they able to do this, and how can you implement your own operations that clean up after themselves?
Scope of a lifetime
Every operation in Effection runs in the context of an associated scope which
places a hard limit on how long it will live. For example, the script below uses
the spawn()
operation to run an operation concurrently that counts to
ten, outputting one number every second. It then sleeps for five seconds before
returning;
import { main, sleep, spawn } from "effection";
await main(function* () { // <- parent scope
yield* spawn(function* () { // <- child scope
for (let i = 1; i = 10; i++) {
yield* sleep(1000);
console.log(i);
}
});
yield* sleep(5000);
});
It may surprise you to learn that this script will only output five numbers, not ten. This is because the main operation completes after just five seconds, and as a result its scope, and every scope it contains, is halted. This in turn means that our counting operation is shutdown and outputs nothing more.
💡Key Concept: no operation may outlive its scope.
This simple rule is incredibly powerful! It means that you can create all kinds of dynamic, concurrent processes, but the moment that they pass out of scope and are no longer needed, they are immediately stopped. If we think about it, this is very similar to the concept of lexical scope in JavaScript with which we're already familiar.
{
let left = 1;
let right = 2;
console.log(`${left} + ${right} = ${left + right}`)
}
console.log(left) // <= ReferenceError left is not defined
In the snippet above, we allocate two number variables: left
and right
.
Inside the "curlys" we can read and write from them all we want, but once we
leave, JavaScript is going to deallocate them and the memory they hold
automatically for us and because of that, we aren't allowed to touch
them ever again. We describe this situation by saying that the variables
have "passed out of scope" and so any resources they hold (in this case
computer memory) can be automatically reclaimed.
Effection applies this very same principle to entire operations, not just variable references. Because of this, once the outcome of an operation becomes known, or it is no longer needed, that operation and all of the operations it contains can be safely shut down.
The Three Outcomes
There are only three ways an operation may pass out of scope.
- return the operation completes to produce a value.
- error the operation fails and exits with an exception.
- halt due to a return, error or a halt of a related operation, an operation is halted.
No matter which one of these happens, every sub-operation associated with that operation will be automatically destroyed.
Suspend (it's not the end)
In order to understand the lifecycle of an Operation, we must first understand the concept of halting a Task.
In Effection, any task can be halted:
import { run } from 'effection';
let task = run(function*() {
yield* suspend();
});
await task.halt();
Halting a Task means that its operation is canceled, and it also causes any operation created by that operation to be halted.
immediate return
If an Operation is expressed as a generator (most are), we call return()
on the generator when that operation is halted. This
behaves somewhat similarly to if you would replace the current yield*
statement with a return
statement.
Let's look at an example where a task is suspended using yield*
with no
arguments and what happens when we call halt
on it:
import { main, suspend } from 'effection';
let task = main(function*() {
yield* suspend(); // we will "return" from here
console.log('we will never get here');
});
await task.halt();
This would behave somewhat similarly to the following:
import { main } from 'effection';
main(function*() {
return;
console.log('we will never get here');
});
Crucially, when this happens, just like with a regular return
, we can use
try/finally
:
import { run, sleep, suspend } from 'effection';
let task = run(function*() {
try {
yield* suspend() // we will "return" from here
} finally {
console.log('yes, this will be printed!');
}
});
await task.halt();
Cleaning up
We can use this mechanism to run code as an Operation is shutting down regardless of whether it completes successfully, is halted, or results in an error.
Imagine that we're doing something with an HTTP server, and we're using node's
createServer
function. To properly clean up after ourselves, we
should call close()
on the server when we're done.
Using Effection and try/finally
, we could do something like this:
import { run, suspend } from 'effection';
import { createServer } from 'http';
let task = run(function*() {
let server = createServer();
try {
// in real code we would do something more interesting here
yield* suspend();
} finally {
server.close();
}
});
await task.halt();
Asynchronous halt
You might be wondering what happens when we yield*
inside the
finally block. In fact, Effection handles this case for you:
import { run, sleep, suspend } from 'effection';
let task = run(function*() {
try {
yield* suspend();
} finally {
console.log('this task is slow to halt');
yield* sleep(2000);
console.log('now it has been halted');
}
});
await task.halt();
While performing asynchronous operations while halting is sometimes necessary, it is good practice to keep halting speedy and simple. We recommend avoiding expensive operations during halt where possible, and avoiding throwing any errors during halting.
Ensure
Sometimes you want to avoid the rightward drift of using lots of
try/finally
blocks. The ensure
operation that ships with
Effection can help you clean up this type of code.
The following behaves identically to our try/finally
implementation above:
import { run, ensure } from 'effection';
import { createServer } from 'http';
let task = run(function*() {
let server = createServer();
yield* ensure(() => server.close());
// in real code we would do something more interesting here
yield* suspend();
});
await task.halt();
Abort Signal
While cancellation and teardown are handled automatically for us as long as we
are using Effection operations, what do we do when we want to integrate with a
3rd party API? One very common answer is to use the JavaScript standard
AbortSignal
which can broadcast an event whenever it is time for an operation to be
canceled. Effection makes it easy to create abort signals and pass them around
so that they can notify dependencies whenever an operation terminates.
To create an abort signal, we can invoke the
useAbortSignal()
operation that comes with
Effection.
AbortSignal
s instantiated with the
useAbortSignal()
operation are implicitly bound
to the scope in which they were created, and whenever that task ceases
running, they will emit an abort
event.
import { main, sleep, useAbortSignal } from 'effection';
await main(function*() {
let signal = yield* useAbortSignal();
signal.addEventListener('abort', () => console.log('done!'));
yield* sleep(5000);
// prints 'done!'
});
It is very common (though not universal) for APIs that perform
asynchronous operations to accept an AbortSignal
to make
sure those operations go away if needed. For example, the standard
fetch
function
accepts an abort signal to cancel itself when needed.
function* request(url) {
let signal = yield* useAbortSignal();
let response = yield* fetch('/some/url', { signal });
if (response.ok) {
return yield response.text();
} else {
throw new Error(`failed: ${ response.status }: ${response.statusText}`);
}
}
Now, no matter what happens, when the request
operation is completed (or
canceled), the HTTP request is guaranteed to be shut down.
Embedding API
The nice thing about scope is that you don't need to worry about it. It's just
there, ensuring that things get cleaned up as soon as they are no longer needed.
Sometimes, however, it is necessary to interact with the scope of an operation
from outside of Effection in normal JavaScript execution. To do this we use the
Scope API. Once we have a reference to a Scope
, we can use it to run
operations as though they were being spawned directly from an operation running
in that scope. An example of this might be to spawn a new operation for each
request to an express server.
Express handles the http request, but it does not know anything about Effection, and so we have to explicitly connect each request to an operation.
import { call, ensure, main, useScope, useAbortSignal, suspend } from "effection";
import express from "express";
await main(function*() {
// capture a reference to the current scope.
let scope = yield* useScope();
express().get("/", async (req, res) => {
// use the scope to spawn an operation within it.
return await scope.run(function*() {
let signal = yield* useAbortSignal();
let response = yield* call(fetch(`https://google.com?q=${req.params.q}`, { signal }));
res.send(yield* call(response.text()));
});
});
let server = express.listen();
yield* ensure(() => server.close());
yield* suspend();
});
And, because every request runs inside of our main scope when that scope
exits because someone hit CTRL-C
, then any in-flight requests will be
canceled.
The other way to get a reference to a Scope
is to create a completely
new one from scratch. This can be done with the createScope()
function. This will create a fresh scope that is completely disconnected from
any other scope.
One common use of createScope()
is for running operations in
test cases.
let scope;
let destroy;
beforeEach(() => {
[scope, destroy] = createScope();
});
it("does something", async () => {
await scope.run(function* () {
// run operations in test case
});
});
afterEach(async () => {
await destroy();
});
Whenever you use createScope()
, it is important that you await
the
destruction operation when the scope is no longer needed since it will not
be destroyed implicitly.