Controlled demoltion of async
/await
applications.
What | Where |
---|---|
Discussion | #1 |
Documentation | https://bigeasy.github.io/destructible |
Source | https://github.com/bigeasy/destructible |
Issues | https://github.com/bigeasy/destructible/issues |
CI | https://travis-ci.org/bigeasy/destructible |
Coverage: | https://codecov.io/gh/bigeasy/destructible |
License: | MIT |
Destructible installs from NPM.
npm install destructible
Destructible manages the concurrent asynchronous code paths in your application.
At the very least, it provides the functionality of Promise.allSettled()
but
with dependencies, error handling and reporting, and cancellation.
At the very most it is a framework for structured concurrency.
This README.md
is also a unit test using the
Proof unit test framework. We'll use the
Proof okay
function to assert out statements in the readme. A Proof unit test
generally looks like this.
require('proof')(4, async okay => {
okay('always okay')
okay(true, 'okay if true')
okay(1, 1, 'okay if equal')
okay({ value: 1 }, { value: 1 }, 'okay if deep strict equal')
})
You can run this unit test yourself to see the output from the various code sections of the readme.
git clone git@github.com:bigeasy/destructible.git
cd destructible
npm install --no-package-lock --no-save
node test/readme.t.js
The 'destructible'
module exports a single Destructible
object.
const Destructible = require('destructible')
Destructible is a utility for managing the construction and destruction of
concurrent paths of execution implicit in async
/await
style JavaScript
programs.
async
/await
lets you wait for results from other threads in your program
messages from your operating system. It also allows you to jump from one code
path to another other within your program. When we're jumping around from one
code path to another within our program we're doing co-operative multi-tasking.
As you're well aware, if you have an endless synchronous loop in your JavaScript
program, your JavaScript interpreter will not pause the loop to let another part
of your program run. That's because your JavaScript code runs in a single
thread. When you return from an async
function or yield
from a generator
you're allowing the path of execution in your program that was await
ing that
function or generator to resume its path of execution.
In other co-operative multi-tasking platforms these co-operative paths of execution are called fibers. Threads use pre-emptive scheduling, whereas fibers use co-operative scheduling.
In Destructible we call these co-operative paths of execution strands. We do this so as not to confuse the reader who reads some part of our documentation, goes off to Google, and comes back with questions about green threads, coroutines or the many other fiber related concepts that are not directly applicable to Destructible.
A strand is defined by the async
/await
call stack created when you call
an async
function without using await
to get the result before proceeding.
Here is a minimal JavaScript program that will create a single strand in its lifetime.
const fs = require('fs').promises
async function main () {
console.log('file size', (await fs.stat(__filename)).size)
}
main()
When Node.js runs our program, it creates a wrapper function around the entirety
of our program. This is not a strand according to the Destructible definition
because it is not an async
function.
Because it is not an async
function we cannot await main()
because you can
only use await
within an async
function. Therefore, when we call main()
we
create a strand.
If main
where to raise an exception we would then get an
'unhandledRejection'
error in Node.js. We can handle the rejection ourselves
using Promise.catch()
but that does not change the number of strands in the
program according to the Destructible definition of strand.
const fs = require('fs').promises
async function main () {
console.log('file size', (await fs.stat(__filename)).size)
}
main().catch(error => console.log(error.message))
We will no longer have an 'unhandledRejection'
exception because we handle it
ourselves.
Here is a program that creates two strands that co-operate to solve a problem, that problem being converting a base 10 number to another base.
const fs = require('fs').promises
const path = require('path')
class Queue {
constructor () {
this._promise = new Promise(resolve => this._notify = resolve)
this._queue = []
}
push (value) {
this._queue.push(value)
this._notify()
}
async shift (value) {
for (;;) {
if (this._queue.length == 0) {
await this._promise
this._promise = new Promise(resolve => this._notify = resolve)
continue
}
return this._queue.shift()
}
}
}
async function list (queue, directory, root = true) {
const dir = await fs.readdir(directory)
for (const file of await fs.readdir(directory)) {
const filename = path.join(directory, file)
const stat = await fs.stat(filename)
if (stat.isDirectory()) {
await list(queue, filename, false)
} else {
queue.push(stat)
}
}
if (root) {
queue.push(null)
}
}
async function sum (queue) {
let sum = 0
for (;;) {
const stat = await queue.shift()
if (stat == null) {
break
}
sum += stat.size
}
return sum
}
const queue = new Queue
list(queue, __dirname)
sum(queue).then(sum => console.log('sum', sum))
TODO Left off here. Probably need to stress that this example program is in fact a toy, would easily implemented as a single strand with a generator.
Basic destructible usage.
const destructible = new Destructible('example')
const work = [ 1, 2, 3, 4 ].map(work => Promise.resolve(work))
let sum = 0
destructible.ephemeral('loop', async () => {
while (work.length != 0) {
sum += await work.shift()
}
})
await destructible.destroy().promise
okay(sum, 10, 'basic destructible')
Destructible does not provide any sort of error recovery mechanism, it assumes
that you will perform any error recovery in your appliction strands using
try
/catch
or what-have-you. If a strand rejects, the Destructible will be
destroyed and destruction will begin.
There is however an error notification mechanism so that a service can detect the failure of other services so that it doesn't attempt to perform an orderly shutdown when the services it depends upon are in an unstable state.
If you have an embedded database service and it fails to write because the disk is full, it should probably not attempt to perform its orderly database shutdown procedure. It would be better to perform a recovery procedure after the system administrator makes some space on disk and restarts the program.
You can determine if a Destructible exited with an error using the errored
property. This is a synchronous property that will return false
until the
Destructible destructs, then it will return return true
if the Destruction was
due to an error.
This property exists on all the Destructibles in the destructible tree. If any
fail then all destructibles will have their errored property set, so the
errored
property is really a property of the destructible tree, not of any
individual destructible itself.
const destructible = new Destructible('destructible')
const child = destructible.durable('child')
const sibling = destructible.durable('sibling')
child.durable('errored', async () => { throw new Error('reject') })
try {
await destructible.promise
} catch (error) {
okay(child.errored, 'child errored')
okay(sibling.errored, 'sibling errored')
okay(destructible.errored, 'parent errored')
okay(child.errored && sibling.errored && destructible.errored, 'everyone errored')
}
An error in one service is not always an error in every service. Using our database service example, an error in the database service means that all other services should stop work since their work cannot be saved. But an error in a service that depends on the database service shouldn't stop the database service from saving the writes it has queued and shutting down in an orderly fashion.
We can isolate errors in sub-trees of the destructible tree using the isolated
property when we create a sub-destructible. When an error occurs outside of the
isolated sub-tree, the destructibles in the isolated sub-tree will not have
their errored
property set.
const destructible = new Destructible('top')
const outside = destructible.durable('outside')
const group = destructible.durable({ isolated: true }, 'group')
const sibling = group.durable('sibling')
outside.durable('errored', async () => { throw new Error('error') })
try {
await destructible.promise
} catch (error) {
okay(destructible.errored, 'root errored')
okay(!group.errored, 'group errored')
okay(!group.sibling, 'sibling errored')
okay(outside.errored, 'outside errored')
}
When an error occurs inside of the isolated sub-tree, the destructibles outside
the isolated sub-tree will have their errored
property set.
const destructible = new Destructible('top')
const outside = destructible.durable('outside')
const group = destructible.durable({ isolated: true }, 'group')
const sibling = group.durable('sibling')
group.durable('errored', async () => { throw new Error('error') })
try {
await destructible.promise
} catch (error) {
okay(destructible.errored, 'root errored')
okay(sibling.errored, 'sibling errored')
okay(outside.errored, 'outside errored')
}
Error can occur after destruction.
At destruction a service might wait for another service to drain but will skip
the drain if the destructible tree is errored
because the drain might never
come. If the destructible tree enters an errored
state after destruction, this
service has already begin waiting. It needs a way to be notified so it can
cancel its wait on drain.
For this notification we can register a panic handler using panic()
. The panic
handler must be synchronous. It can launch new ephemerals, but I doubt that's a
good idea. If I do so myself I'll come back and talk about the use case.
The panic handler is supposed in indicate that an error occurred after
shutdown, not before. The panic()
handler will only be called after
destruct()
and only if the the destructible was not already errored
when it
was destructed.
In this example the shutdown of a consumer is expecting an a producer to signal
it is done by releasing a drain
latch. Before the producer can release the
latch, however, it raises an exception. The consumer registered a panic handler
that will release the latch itself so that the consumer shutdown will complete.
const destructible = new Destructible('destructible')
const test = []
const drain = function () {
let capture
return {
promise: new Promise(resolve => capture = { resolve }),
...capture
}
} ()
const producer = destructible.durable('producer')
producer.destruct(() => test.push(`producer errored: ${producer.errored}`))
producer.panic(() => test.push('producer panicked'))
const consumer = destructible.ephemeral('consumer')
consumer.destruct(() => {
test.push(`consumer errored: ${consumer.errored}`)
consumer.ephemeral('shutdown', async () => {
await drain.promise
})
})
consumer.panic(() => {
test.push('consumer panicked')
drain.resolve()
})
consumer.destroy()
producer.durable('rejected', async () => {
throw new Error('reject')
drain.resolve()
})
try {
await destructible.promise
} catch (error) {
okay(test, [
'consumer errored: false', 'consumer panicked', 'producer errored: true', 'producer panicked'
], 'panic')
}
Panic handlers will not run if the destructible resolves or scrams. TODO No longer true. Updated test. Did not update README.
In this example we wait for the sibling to shutdown completely before the child raises an exception. Because the sibling shutdown completely, it's panic handler is not called.
const destructible = new Destructible('destructible')
const panic = []
const child = destructible.durable('child')
child.panic(() => panic.push('child panicked'))
const sibling = destructible.ephemeral('sibling')
sibling.panic(() => panic.push('sibling panicked'))
sibling.destroy()
await sibling.promise
child.durable('rejected', async () => { throw new Error('reject') })
try {
await destructible.promise
} catch (error) {
okay(panic, [ 'child panicked' ], 'no panic')
}
After destruction service might be waiting for another service to drain but that drain notification might never arrive be
You can use the errored property to determine if an operation should be performed or skipped during. If you have a work queue, once errored you may decide to skip the work in the queue and let the queue empty quickly. You may have shutdown ephemerals strands that you won't perform on error exit.
In our database example, we might write some state information to disk so that the next time the program runs it can resume quickly. If the database is in a bad state we probably don't want to write the state information because we can't trust it.
TODO Code exmaple.
At times we might want to isolate the error property in our tree, so that a particular sub-tree will not be marked as errored if the error occured in a branch outside the sub-tree.
In our database example, we might have an error originating outside the strands that compose the database. The database itself is in a fine state and can perform an orderly shutdown, so it may as well attempt to do so.
TODO Code exmaple.
If we've isolated a sub-tree, there may be times when a service in that sub-tree is doing work in an unknown strand. Our database may do its writes in a work queue that is managed by a queue service. If the database write fails and it throws an exception, it will get caught by the queue service strand and shut it down with an error, but we need to keep the queue running so other services besides the database can clean up. We'd rather have the destructible associated with database service report the exception instead of the destructible associated with the queue service.