Effection Logo

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.

  1. return the operation completes to produce a value.
  2. error the operation fails and exits with an exception.
  3. 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.

AbortSignals 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.

  • NextProcesses