Effection Logo

Actions and Suspensions

In this section, we'll cover the first two fundamental operations in Effection: suspend() and action(), and how we can use them in tandem to serve as a safe alternative to the Promise constructor.

Suspend

Simple in concept, yet bearing enormous practical weight, the suspend() operation is fundamental to Effection. It pauses the current operation until it passes out of scope, at which point it will return immediately.

Let's revisit our simplified sleep operation from the introduction to operations:

export function sleep(duration) {
  return action(function* (resolve) {
    let timeoutId = setTimeout(resolve, duration);
    try {
      yield* suspend();
    } finally {
      clearTimeout(timeoutId);
    }
  });
}

As we saw, no matter how the sleep operation ends, it always executes the finally {} block on its way out; thereby clearing out the setTimeout callback.

It's worth noting that we say the suspend operation will return immediately, we really mean it. The operation will proceed to return from the suspension point via as direct a path as possible, as though it were returning a value.

export function sleep(duration) {
  return action(function* (resolve) {
    let timeoutId = setTimeout(resolve, duration);
    try {
      yield* suspend();
      console.log('you will never ever see this printed!');
    } finally {
      clearTimeout(timeoutId);
    }
  });
}

If we wanted to replicate our sleep() functionality with promises, we'd need to do something like accept an AbortSignal as a second argument to sleep(), and then use it to prevent our event-loop callback from leaking:

export function sleep(duration, signal) {
  return new Promise((resolve) => {
    if (signal.aborted) {
      resolve();
    } else {
      let timeoutId = setTimeout(resolve, duration);
      signal.addEventListener("abort", () => clearTimeout(timeoutId));
    }
  });
}

This functions properly, but is ham-fisted. Not only is the implementation non-obvious, but it's also cumbersome to use in practice because it involves first creating a signal, passing it around explicitly to everything, and then finally firing it manually when the program is over:

let controller = new AbortController();
let { signal } = controller;

await Promise.all([sleep(10, signal), sleep(1000, signal)]);

controller.abort();

With a suspended action on the other hand, we get all the benefit as if an abort signal was there without sacrificing any clarity in achieving it.

💡Fun Fact: suspend() is the only true 'async' operation in Effection. If an operation does not include a call to suspend(), either by itself or via a sub-operation, then that operation is synchronous.

Most often, but not always, you encounter suspend() in the context of an action as the pivot between that action's setup and teardown.

Actions

The second fundamental operation, action(), serves two purposes. The first is to adapt callback-based APIs and make them available as operations. In this regard, it is very much like the promise constructor. To see this correspondance, let's use one of the examples from MDN that uses promises to make a crude replica of the global fetch() function. It manually creates an XHR, and hooks up the load and error events to the resolve and reject functions respectively.

async function fetch(url) {
  return await new Promise((resolve, reject) => {
    let xhr = new XMLHttpRequest();
    xhr.open("GET", url);
    xhr.onload = () => resolve(xhr.responseText);
    xhr.onerror = () => reject(xhr.statusText);
    xhr.send();
  });
}

Consulting the Async Rosetta Stone, we can substitute the async constructs for their Effection counterparts to arrive at a line for line translation.

function* fetch(url) {
  return yield* action(function*(resolve, reject) {
    let xhr = new XMLHttpRequest();
    xhr.open("GET", url);
    xhr.onload = () => resolve(xhr.responseText);
    xhr.onerror = () => reject(xhr.statusText);
    xhr.send();
  });
}

While this works works every bit as well as the promise based implementation, it turns out that the example from MDN has a subtle bug. In fact, it's the same subtle bug that afflicted the "racing sleep" example in the introduction to operations. If we no longer care about the outcome of our fetch operation, we will "leak" its http request which will remain in flight until a response is received. In the example below it does not matter which web request "wins" the race to fetch the current weather, our process cannot exit until both requests are have received a response.

await Promise.race([
  fetch("https://openweathermap.org"),
  fetch("https://open-meteo.org")
])

With Effection, this is easily fixed by suspending the operation, and making sure that the request is cancelled when it is either resolved, rejected, or passes out of scope.

function* fetch(url) {
  return yield* action(function*(resolve, reject) {
    let xhr = new XMLHttpRequest();
    xhr.open("GET", url);
    xhr.onload = () => resolve(xhr.responseText);
    xhr.onerror = () => reject(xhr.statusText);
    xhr.send();
    try {
      yield* suspend();
    } finally {
      xhr.abort();
    }
  });
}

💡Almost every usage of the promise concurrency primitives will contain bugs born of leaked effects.

As we've seen, actions can do anything that a promise can do (and more safely at that), but they also have a super power that promises do not. If you recall from the very beginning of this article, a key difference in Effection is that operations are values which, unlike promises, do not represent runtime state. Rather, they are "recipes" of what to do, and in order to do them, they need to be run either explicitly with run() or by including them with yield*.

This means that when every operation runs, it is bound to an explicit lexical context; which is a fancy way of saying that running an operation can only ever return control to a single location. A promise on the other hand, because it accepts an unlimited number of callbacks via then(), catch(), and finally(), can return control to an unlimited number of locations. This may seem a small thing, but it is very powerful. To demonstrate, consider the following set of nested actions.

await run(function* () {
  yield* action(function* (resolve) {
    try {
      yield* action(function*() {
        try {
          yield* action(function*() { resolve() });
        } finally {
          console.log('inner')
        }
      });
    } finally {
      console.log('middle');
    }
  });
  console.log('outer');
});

When we run it, it outputs the strings inner, middle, and outer in order. Notice however, that we never actually resolved the inner actions, only the outer one, and yet every single piece of teardown code is executed as expected as the call stack unwinds and it proceeds back to line 2. This means you can use actions to "capture" a specific location in your code as an "escape point" and return to it an any moment, but still feel confident that you won't leak any effects when you do.

Let's consider a slightly more practical example of when this functionality could come in handy. Let's say we have a bunch of numbers scattered across the network that we want to fetch and multiply together. We want to write an to muliply these numbers that will use a list of operations that retreive the numbers for us.

In order to be time efficient we want to fetch all the numbers concurrently so we use the all() operation. However, because this is multiplication, if any one of the numbers is zero, then the entire result is zero, so if at any point we discover that there is a 0 in any of the inputs to the computation, there really is no further point in continuing because the answer will be zero no matter how we slice it. It would save us time and money if there were a mechanism to "short-circuit" the operation and proceed directly to zero, and in fact there is!

The answer is with an action.

import { action, all } from "effection";

export function multiply(...operations) {
  return action(function* (resolve) {
    let fetchNumbers = operations.map(operation => function* () {
      let num = yield* operation;
      if (num === 0) {
        resolve(0);
      }
      return num;
    });

    let values = yield* all(fetchNumbers);

    let result = values.reduce((current, value) => current * value, 1);

    resolve(result);
  });
}

We wrap each operation that retrieves a number into one that immediately ejects from the entire action with a result of zero the moment that any zero is detected in any of the results. The action will yield zero, but before returning control back to its caller, it will ensure that all outstanding requests are completely shutdown so that we can be guaranteed not to leak any effects.

  • PreviousOperations
  • NextResources