Sam Thorogood

AbortController for expiry

My last post on AbortController continues to be my most read blog post, ever. This one probably won't do quite as well, but that's fine—it's a bit more niche, but it's definitely also an extension of that one.

Today, the problem I'll solve is:

Background

The idea of AbortController is to generate an AbortSignal you can pass in to some object or system to later shut it down. For example, you can have one signal for an entire request, but shut down all its dependent work with one single call to .abort().

Diagram showing creating and aborting an AbortController
Create an AbortController, do some stuff, get out

This background might feel a bit needless, but it actually reminds you that the AbortController has a very close relationship to the idea of "context" as it pertains to RPCs, network requests and so on: whenever your service handles an external request, create a single AbortController, and pass its signal (aka "context") everywhere.

If you're familiar with Go, then it has a standard library version of this.

Anyway, read on. 👇

Approach

To remind you, we're here to manage the expiry of some complex object: something that itself has a lifetime.

So instead of starting with a context-like object, you can create a helper function that builds your thing and its lifetime—where its lifetime is described via an AbortSignal. Something like:

/**
 * Builds an expirable role. Creates a new lifetime in its AbortSignal.
 */
async function buildExpirableRole() {
  const { expiresAt, token } = await getRole();  // the hard work!

  const c = new AbortController();
  const expiresAfter = (+expiresAt - +new Date);  // gives back an expiry Date
  setTimeout(() => c.abort(), expiresAfter);

  return { result: token, signal: c.signal };
}

When I call this method, I now get a result and a signal pair. I can use this to determine how long the result is valid for: has the signal been aborted? If so, it's no longer valid. 🙅

Re-using existing lifetimes

I've just created an expirable object that returns a new AbortSignal. What if the object has a lesser lifetime than some outer context? Then, you can just pass in an existing AbortSignal and create effectively a derived one:

async function buildDerivedExpirableRole(signal) {
  const { expiresAt, token } = await getRole();
  const c = new AbortController();
  const expiresAfter = (+expiresAt - +new Date);
  setTimeout(() => c.abort(), expiresAfter);

  // NEW CODE HERE
  if (signal.aborted) {
    c.abort();
  }
  signal.addEventListener('abort', () => c.abort());
  // END NEW CODE

  return { result: token, signal: c.signal };
}

We literally make the inner signal's lifetime shorter by piggybacking on the outer one's aborted state. In doing so, you'll have to deal with the awkward nature of AbortSignal: if it's already aborted, you can't just add an event listener, because it's already fired. (I have a helper for this, since I do this so often.)

Memoizing This Approach

Okay, you've got a thing and its lifetime—a pair of state. You can call this, but now you've got to hold onto this pair forever.

Instead, you'll want to memoize this: create a function which will automatically fetch a new value whenever the previous' signal has aborted/expired.

This is pretty simple. If you have some type ExpirableResult<R>, that contains the result and a .signal property (in TypeScript), then you can create a helper which holds onto the Promise while it is valid.

I've swapped into TypeScript for this one, since the types help a lot:

export function memoizeExpirable<R>(
  fn: () => Promise<ExpirableResult<R>>,
): () => Promise<ExpirableResult<R>> {
  let activePromise: Promise<ExpirableResult<R>> | undefined;

  return () => {
    if (activePromise !== undefined) {
      return activePromise;  // return already active promiswe
    }

    // there's no valid result, fetch a new one
    activePromise = fn().then((ret) => {
      if (ret.signal.aborted) {
        // already aborted! clear result immediately
        activePromise = undefined;
      }
      ret.signal.addEventListener('abort', () => activePromise = undefined);
      return ret;
    });

    return activePromise;
  };
}

Okay, that's a bit of code, but it's all fairly required. Again, this has the awkward part of managing an already aborted vs. newly aborted AbortSignal.

To use, you might do something like:

const getExpirableRole = memoizeExpirable(buildExpirableRole);

Now, you can call getExpirableRole—it'll return a Promise—to always get a valid role. Amaze! 🎉

An interlude on lifetimes and context

The previous example just accepts a function which computes an outcome with an expiry, but it doesn't accept a previous signal that has its own lifetime.

This is interesting to think about because you basically have two lifetimes when writing any sort of application.

  1. The lifetime of a user's request (e.g., a network handler on a backend, a RPC), or interaction (perhaps as the user undertakes a complex interaction)
  2. The lifetime of the app as a whole.

You might also have a concept of a "session", although this is hazy on the web.

Shows a diagram of requests living within the total lifetime of a server
The two lifetimes to deal with in server-land

Often these interactions are inexorably linked. Cloud Functions, or Lambdas, tend to be created on-demand on a user's initial request—they don't run all the time, otherwise you're paying the cost for that even when your server isn't being used. Lambdas will then have a keep-alive time when they can handle further requests (although this may only be a single request, if your activity is low).

So if you're getting a credential or role… what is the right "context", ala, the global AbortSignal to use? If it's the lifetime—well, you might not have a signal at all, because modern backends are overwhelmingly designed "to die". So "to die" is basically when the server just shuts down in an uncontrolled manner.

(Something I learnt early-on in my technical career is that no-one is ever "neatly" shutting down a backend in a controlled way. You build a thing, it's designed to run for as long as it can or to handle as many requests as possible, and there's no such thing as 'normal' shutdown. 🧨)

Thanks

Thanks! That's all for today.