Skip to content

Commit

Permalink
Merge pull request #957 from thefrontside/v4-docs-suspend
Browse files Browse the repository at this point in the history
📝 update the `suspend()` docs to account for v4 API
  • Loading branch information
cowboyd authored Jan 6, 2025
2 parents cdf515c + a263654 commit e8232b6
Show file tree
Hide file tree
Showing 5 changed files with 70 additions and 189 deletions.
23 changes: 23 additions & 0 deletions lib/suspend.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
import { action } from "./action.ts";
import type { Operation } from "./types.ts";

/**
* Indefinitely pause execution of the current operation.
*
* A suspended operation will remain paused until its enclosing scope
* is destroyed, at which point it proceeds as though return had been
* called from the point of suspension.
*
* @example
* ```js
* import { main, suspend } from "effection";
*
* await main(function* (resolve) {
* try {
* console.log('suspending');
* yield* suspend();
* } finally {
* console.log('done!');
* }
* });
* ```
*
* @returns an operation that suspends the current operation
*/
export function suspend(): Operation<void> {
return action(() => () => {}, "suspend");
}
177 changes: 25 additions & 152 deletions www/docs/actions.mdx
Original file line number Diff line number Diff line change
@@ -1,51 +1,24 @@
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][promise-constructor].
In this section, we'll cover one of the most fundamental operations in
Effection: `action()`, and how we can use it as a safe alternative to
the [Promise constructor][promise-constructor].

## Suspend
## Example: Sleep

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][scope], at which point it will return
immediately.

Let's revisit our simplified sleep operation from the [introduction to
Let's revisit our sleep operation from the [introduction to
operations](./operations):


```js
export function sleep(duration) {
return action(function* (resolve) {
export function sleep(duration: number): Operation<void> {
return action((resolve) => {
let timeoutId = setTimeout(resolve, duration);
try {
yield* suspend();
} finally {
clearTimeout(timeoutId);
}
return () => 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.

```js {6}
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);
}
});
}
```
`clearTimeout()` on its way out.

If we wanted to replicate our `sleep()` functionality with promises, we'd need
to do something like accept an [`AbortSignal`][abort-signal] as a second
Expand Down Expand Up @@ -80,22 +53,13 @@ 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
With an 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][spawn-suspend], you encounter `suspend()` in the
context of an action as the pivot between that action's setup and teardown.

## Actions
## Action Constructor

The second fundamental operation, [`action()`][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][promise-constructor]. To see this correspondance, let's
The [`action()`][action] function provides a callback based API to create Effection operations. You don't need it all that often, but when you do it functions almost exactly like the
[promise constructor][promise-constructor]. To see this, let's
use [one of the examples from MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise#examples)
that uses promises to make a crude replica of the global [`fetch()`][fetch]
function. It manually creates an XHR, and hooks up the `load` and `error` events
Expand All @@ -114,17 +78,19 @@ async function fetch(url) {
```

Consulting the [Async Rosetta Stone](async-rosetta-stone), we can substitute the async
constructs for their Effection counterparts to arrive at a line for line
translation.
constructs for their Effection counterparts to arrive at an (almost) line for line
translation. The only significant difference is that unlike the promise constructor, an
action constructor _must_ return a "finally" function to exit the action.

```js
function* fetch(url) {
return yield* action(function*(resolve, reject) {
return yield* action((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(xhr.statusText);
xhr.send();
return () => { } // "finally" function place holder.
});
}
```
Expand All @@ -133,7 +99,7 @@ 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](http://localhost:8000/docs/operations#cleanup). If
operations](../operations#cleanup). 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
Expand All @@ -147,119 +113,26 @@ await Promise.race([
])
```

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
With Effection, this is easily fixed by calling `abort()` in the finally function to
make sure that the request is cancelled when it is either resolved, rejected, or
passes out of scope.

```js {8-12} showLineNumbers
```js {8} showLineNumbers
function* fetch(url) {
return yield* action(function*(resolve, reject) {
return yield* action((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();
}
return () => { xhr.abort(); }; // called in all cases
});
}
```

>💡Almost every usage of the [promise concurrency primitives][promise-concurrency]
> 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.

```js
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()`][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.

```ts
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.

[abort-signal]: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/fetch
Expand Down
50 changes: 18 additions & 32 deletions www/docs/operations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ await main(function*() {
There is no limit to the number of operations that can be nested within
each other, and in fact, composition is so core to how operations work that
_every operation in Effection eventually boils down to a combination of
just three primitive operations_: `action()`, `resource()`, and `suspend()`.
just two primitive operations_: `action()` and `resource()`.

## Cleanup

Expand Down Expand Up @@ -205,48 +205,34 @@ await main(function*() {
```

If we look at the implementation of the `sleep()` operation in Effection, we can
see how this works. It uses two of the fundamental operations:
`action()`, and `suspend()`, to wait until the duration has elapsed.
see how this works. It uses the fundamental `action()` operation to pause execution until
the duration has elapsed.

```js {6-8} showLineNumbers
export function sleep(duration) {
return action(function* (resolve) {
```js {3-4} showLineNumbers
export function sleep(duration: number): Operation<void> {
return action((resolve) => {
let timeoutId = setTimeout(resolve, duration);
try {
yield* suspend();
} finally {
clearTimeout(timeoutId);
}
return () => clearTimeout(timeoutId);
});
}
```

However, there is a key difference between this version of `sleep()` and the
earlier one we wrote using promises. In this `Operation` based implementation,
there is a `finally {}` block which clears the timeout callback, and it is
because of this that the Effection `sleep()` does not cause the process to hang,
whereas the promise-`sleep()` does.
However, there is a key difference between this version of `sleep()`
and the earlier one we wrote using promises. In this `Operation` based
implementation, there is a "finally" function returned by the action
executor which clears the timeout callback. It is because of this that
the Effection `sleep()` does not cause the process to hang, whereas
the promise-`sleep()` does.

Furthermore, the operation is crystal clear about the order of its execution.
The steps are:

1. install the timeout callback
2. suspend until resolved or discarded.
3. uninstall the timeout callback

If we always follow those steps, then we'll always resume at the right point,
and we'll never leak a timeout effect.

>💡As an alternative to using a `finally {}` block to express your cleanup, you
> can also use the [`ensure()`][ensure] operation which in some cases can add
> clarity. Which method you use is up to you!
Like every operation in Effection, the logic to enter a sleep is
bundled right alongside the logic to exit it. As a result, and we'll
never leak a timeout effect.

## Computational Components

It's amazing to think how so many operations in the Effection
ecosystem can be broken down into a combination of the fundamental:
`action()`, `resource()`, and `suspend()` operations. Composability
is the key to everything.
ecosystem can be broken down into a combination of the fundamental
`action()` and `resource()` operations. Composability is the key to everything.

Once you become accustomed to programming with Effection, you'll come to realize
that the manner in which operations compose resembles in a large part the
Expand Down
2 changes: 1 addition & 1 deletion www/docs/resources.mdx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
The third fundamental Effection operation is [`resource()`][resource]. It can
Another fundamental Effection operation is [`resource()`][resource]. It can
seem a little complicated at first, but the reason for its existence is
rather simple. Sometimes there are operations which meet the following criteria:

Expand Down
7 changes: 3 additions & 4 deletions www/docs/scope.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,7 @@ 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:
Let's look at an example where a task is suspended using `yield* suspend()` and we call `halt` on it:

``` javascript
import { main, suspend } from 'effection';
Expand Down Expand Up @@ -302,8 +301,8 @@ await main(function*() {
// 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 response = yield* call(() => fetch(`https://google.com?q=${req.params.q}`, { signal }));
res.send(yield* call(() => response.text()));
});
});

Expand Down

0 comments on commit e8232b6

Please sign in to comment.