I was thinking some today about long-running computations on function-as-a-service (hereafter FaaS) platforms. FaaS platforms have relatively strict runtime constraints (e.g. 10s on Vercel's unpaid plan, but up to 15m on AWS Lambda, with others usually falling somewhere in-between). When doing computations that are either interruptable (a sequence of less expensive operations) or take place remotely (e.g. expensive API calls to something like an AI service), the ability to suspend and resume these long-running/blocking computations may make it possible to run them on FaaS platforms.
A quick web search told me what I was looking for is called "serializable first-class continuations" and, unfortunately, JS doesn't have support for first-class continuations, much less serializable ones. However, since I was primarily interested in interrupting and serializing computations at await
-points, I thought I might be able to get somewhere by leveraging generators. Unfortunately generators aren't serializable either, but functions are (mostly! The source can be serialized but the closure will be lost). So here's what I came up with:
function anExpensiveComputation(): Promise<number> {
return new Promise((resolve) => setTimeout(() => resolve(42), 1000));
}
function anotherExpensiveComputation(input: number): Promise<number> {
return new Promise((resolve) => setTimeout(() => resolve(input + 1), 1000));
}
function* sequencedComputation(): Generator<() => Promise<number>, number, number> {
const a = yield () => anExpensiveComputation();
const b = yield () => anotherExpensiveComputation(a);
return b;
}
// Now we can create the computation and run it to completion
let snapshotable = SnapshotableComputation.fromGeneratorFn(sequencedComputation);
let result = await snapshotable.run();
console.log(result); // 43
// Or we can run it to a certain point, snapshot it, and then resume it later
snapshotable = SnapshotableComputation.fromGeneratorFn(sequencedComputation);
const resultP = snapshotable.run();
await delay(1500);
const snapshot = snapshotable.snapshot();
persist(snapshot);
// ... later
// the first expensive computation has already finished, it will not be run again
snapshotable = SnapshotableComputation.deserialize(snapshot);
result = await snapshotable.run();
console.log(result); // 43
This approach has a few limitations:
- The closure for the generator function is lost when the snapshot of the computation is created. This means that all referenced functions in the global scope. As far as I know, this limitation can't be overcome without codegen (like https://github.com/nokia/ts-serialize-closures).
- Values returned by the computations must also be serializable. This means you can't get resources which have some external state, such as file handles. (This is inevitable and desireable, since those resources shouldn't live long enough to be available to a resumed computation.)
Additionally, for the usecase I described (running computations on FaaS), you'd need some additional machinery: a way to uniquely identify computations, and a runtime which can dispatch the result of external async computations either to already running or suspended computations which are awaiting that result.
The serializability and closure loss limitations might preclude this approach's usefulness, but it might be possible to find workarounds or bounded applications. Further ideas for exploration/improvement mostly focus around codegen/macros, adding idempotent, retriable operations (to enable external resource acquisition in an idempotent way), and thinking about what it would take to get serializable generators or closures in JS runtime environments.
Further reading:
- Racket blog on their serializable continuations and the docs for the same
- Haskell library: Workflow - a Haskell library which provides a monadic API for interruptable workflows
- Inngest: an infra-as-a-service provider for workflows
- AWS Step Functions - an AWS workflow offering
- Theory and Practice of Coroutines with Snapshots - a paper describing a macro implementation of snapshotable coroutines in Scala
- Stack Overflow: Pickle/Serialize generator state in JavaScript