From 6e589f5e762df97fdb6cdcbc7b7e0fbb2e1fd081 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 4 Jan 2025 07:38:06 -0600 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=93=9D=20update=20the=20`suspend()`?= =?UTF-8?q?=20docs=20to=20account=20for=20v4=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suspend operation is no longer fundamental. It is just an action that does nothing, so we had to update the docs to reflect that. We had to change not only the API docs, but I also noticed that there wer several places where the old `action+suspend` trick was being used that are no longer valid. --- lib/suspend.ts | 23 ++++++ www/docs/actions.mdx | 177 ++++++---------------------------------- www/docs/operations.mdx | 50 ++++-------- www/docs/resources.mdx | 2 +- www/docs/scope.mdx | 7 +- 5 files changed, 70 insertions(+), 189 deletions(-) diff --git a/lib/suspend.ts b/lib/suspend.ts index 2b91b7e5..05be553b 100644 --- a/lib/suspend.ts +++ b/lib/suspend.ts @@ -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 { return action(() => () => {}, "suspend"); } diff --git a/www/docs/actions.mdx b/www/docs/actions.mdx index 00a5827a..695ece81 100644 --- a/www/docs/actions.mdx +++ b/www/docs/actions.mdx @@ -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 { + 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 @@ -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 use 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 @@ -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. }); } ``` @@ -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 @@ -147,23 +113,19 @@ 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 }); } ``` @@ -171,95 +133,6 @@ function* fetch(url) { >💡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 diff --git a/www/docs/operations.mdx b/www/docs/operations.mdx index 320593e9..0656d202 100644 --- a/www/docs/operations.mdx +++ b/www/docs/operations.mdx @@ -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 three primitive operations_: `action()` and `resource()`. ## Cleanup @@ -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 { + 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 logit 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()`. 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 diff --git a/www/docs/resources.mdx b/www/docs/resources.mdx index d8dc130f..30710b3c 100644 --- a/www/docs/resources.mdx +++ b/www/docs/resources.mdx @@ -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: diff --git a/www/docs/scope.mdx b/www/docs/scope.mdx index 66413dee..77fc4631 100644 --- a/www/docs/scope.mdx +++ b/www/docs/scope.mdx @@ -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'; @@ -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())); }); }); From 2938a40c1451f026f9a36ac2c968ab423332068c Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Mon, 6 Jan 2025 06:27:54 -0600 Subject: [PATCH 2/3] Apply suggestions from code review Co-authored-by: David Brochart --- www/docs/actions.mdx | 4 ++-- www/docs/operations.mdx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/www/docs/actions.mdx b/www/docs/actions.mdx index 695ece81..0f2d8b22 100644 --- a/www/docs/actions.mdx +++ b/www/docs/actions.mdx @@ -58,7 +58,7 @@ an abort signal was there without sacrificing any clarity in achieving it. ## Action Constructor -The [`action()`][action] function provides a callback based API to create Effection operations. You don't need use it all that often, but when you do it functions almost exactly like the +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] @@ -125,7 +125,7 @@ function* fetch(url) { xhr.onload = () => resolve(xhr.responseText); xhr.onerror = () => reject(xhr.statusText); xhr.send(); - return () => { xhr.abort(); }; //called in all cases + return () => { xhr.abort(); }; // called in all cases }); } ``` diff --git a/www/docs/operations.mdx b/www/docs/operations.mdx index 0656d202..4994e7db 100644 --- a/www/docs/operations.mdx +++ b/www/docs/operations.mdx @@ -225,13 +225,13 @@ the Effection `sleep()` does not cause the process to hang, whereas the promise-`sleep()` does. Like every operation in Effection, the logic to enter a sleep is -bundled right alongside the logit to exit it. As a result, and we'll +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: +ecosystem can be broken down into a combination of the fundamental: `action()` and `resource()`. Composability is the key to everything. Once you become accustomed to programming with Effection, you'll come to realize From a263654d4a5c4f39022703ca66dafb4873c0e614 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 4 Jan 2025 07:38:06 -0600 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=93=9D=20update=20the=20`suspend()`?= =?UTF-8?q?=20docs=20to=20account=20for=20v4=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suspend operation is no longer fundamental. It is just an action that does nothing, so we had to update the docs to reflect that. We had to change not only the API docs, but I also noticed that there wer several places where the old `action+suspend` trick was being used that are no longer valid. --- www/docs/operations.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/www/docs/operations.mdx b/www/docs/operations.mdx index 4994e7db..7b15f755 100644 --- a/www/docs/operations.mdx +++ b/www/docs/operations.mdx @@ -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()` and `resource()`. +just two primitive operations_: `action()` and `resource()`. ## Cleanup @@ -231,8 +231,8 @@ 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()` and `resource()`. 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