@ggoodman/context
v2.1.0
Published
A take on ergonomic cancellation and timeout propagation in JavaScript inspired by the Go context package
Downloads
22
Maintainers
Readme
@ggoodman/context
A take on ergonomic cancellation and timeout propagation in JavaScript inspired by the Go context package.
In long-lived services and processes, it is difficult to express and manage the different lifetimes of different operations. Typically, at the root there is the process itself. The overall process may be designed to run to completion or indefinitely. In either case, such processes typically can be interrupted by things like OS signals (ie: SIGINT
/ SIGTERM
) and may want to trigger graceful shutdown in such cases.
These long-lived processes are often responding to events like http requests or changes on the file system. In the former case, it is nice to be able to enforce deadlines on the handling of a request. In the latter case, subsequent events may invalidate any outstanding operations. The Context
API is designed to help in these situations by making it easy to represent a hierarchy of nested operations with their own lifetimes.
At a high level, the lifetime of a child Context
will never exceed that of its parent. That means if an ancestor Context
is cancelled--either explicitly via a cancel
function or implicitly by timing out--all of its descendents will themselves be cancelled.
Someone designing a long-lived process that responds to events may want to represent the lifecycle of the overall process as a parent Context that gets cancelled upon certain signals. The operations triggered by the events this process observes might be associated with child Context
. In some cases, these child contexts might be created with internal dealines or timeouts. In others, new events may invalidate prior operations in which case explicit cancellation might be useful.
For more insight on the Go package that inspires this module, please see their introductory blog post.
Installation
npm install --save @ggoodman/context
Example
import { Background, withCancel, withEventEmitter, withTimeout } from '@ggoodman/context';
import Express from 'express';
import * as Stream from 'stream';
const app = Express();
// Wire up some signal handlers for some process-level events that would indicate a need to
// shut down the service.
const appContext = withEventEmitter(
Background(),
process,
['SIGINT', 'SIGTERM', 'uncaughtException', 'unhandledRejection'],
(eventName) => new Error(`Received event ${eventName}`)
);
// Trigger graceful shutdown at the earliest cancellation signal
appContext.onDidCancel(() => app.close());
// Let's create a middleware to make a child context for each request.
// When either the app context or the request is closed, this context and any
// child contexts thereof will close. This let's us stop any expensive operations
// associated with aborted or failed requests.
app.use((req, res, next) => {
// Create the child context and attach it to the request. The child context will
// become cancelled if the request is aborted or the connection is closed. It will
// also become cancelled if the overall app context is cancelled.
const { ctx, cancel } = withTimeout(appContext, 5000);
// Use the finished helper to listen for errors or completion of the
// response and cancel accordingly.
Stream.finished(res, cancel);
ctx.onDidCancel((reason) => {
if (isDeadlineExceededError(reason) && !res.headersSent) {
// If the request-specific timeout gets triggered and we have yet to serve
// this request, we'll serve a 504 error.
res.writeHead(504);
res.end();
}
});
// Stuff the context on to the req object for lack of a simpler to document
// approach.
req.ctx = ctx;
return next();
});
app.get('/', (req, res, next) => {
// We're going to perform some expensive operation and pass this request's context
// to that call. That way, the expensive call can be aborted if the context is
// cancelled.
return performExpensiveOperation(req.ctx).then((result) => res.end(result), next);
});
app.listen(0);
API
Exports
Background()
: A function that returns the top-levelContext
instance that can never be cancelled but from which all application- and library-levelContext
s are derived.isContext(obj)
: Returns aboolean
value based on whether the suppliedobj
is an instance ofContext
. Also acts as a TypeScript type guard.isCancelledError(err)
: Returns aboolean
value based on whether the suppliederr
is an instance ofCancelledError
. Also acts as a TypeScript type guard.isContextError(err)
: Returns aboolean
value based on whether the suppliederr
is an instance of eitherCancelledError
orDeadlineExceededError
. Also acts as a TypeScript type guard.isDeadlineExceededError(err)
: Returns aboolean
value based on whether the suppliederr
is an instance ofDeadlineExceededError
. Also acts as a TypeScript type guard.withCancel(ctx)
: Create a childContext
and a method to cancel that context where:ctx
is a parentContext
instance.
Returns an object with the shape:
cancel(reason)
is a function that will cancel the child context where:reason
is an optional value that will be propagated toonDidCancel
handlers and will become the child Context'scancellationReason
.
ctx
is the childContext
object.
withEventEmitter(ctx, ee, eventNames, reasonFactory)
: Create a childContext
that will be cancelled when the event emitter emits any of the supplied events where:ctx
is a parentContext
instance.ee
is a Node.jsEventEmitter
instance.eventNames
is a singlestring
event name or an array ofstring
event names. The firing of the first event among those supplied will cause the returnedContext
instance to be cancelled.reasonFactory
is an optional function that accepts the theeventName
and any other arguments passed to the emitter's handler. It should return astring
reason explaining the cancellation.
withTimeout(ctx, timeoutMs)
: A function that will return a childContext
that will automatically be cancelled after the supplied timeout and a method to cancel that context where:ctx
is a parentContext
instance.timeoutMs
is an interval in milliseconds after which point the returnedContext
should be cancelled.
Returns an object having a
cancel
method andctx
instance, similar towithCancel()
.A context that gets cancelled due to it timing out will have a
cancellationReason
that is an instance ofDeadlineExceededError
.withDeadline(ctx, epochTimeMs)
: A function that will return a childContext
that will automatically be cancelled at the supplied epoch time and a method to cancel that context where:ctx
is a parentContext
instance.epochTimeMs
is a unix timestamp in millisecond resolution at which point the returned Context should be cancelled.
Returns an object having a
cancel
method andctx
instance, similar towithCancel()
.A context that gets cancelled due to it passing its deadline will have a
cancellationReason
that is an instance ofDeadlineExceededError
.
Context
Context
is an interface representing a node in the context hierarchy that may or may not be cancelled. A child context created from a parent context that is already cancelled will itself be created in the cancelled state. Listners registered using onDidCancel
on a cancelled context will be fired asynchronously.
cancellationReason
: The reason for the cancellation, if the context is cancelled.onDidCancel(handler)
: Register an event handler for when the context is cancelled, returning an object with adispose()
method, where:handler
is a function that will be invoked with the cancellation reason if and when the Context is cancelled.
Future work
- Provide
toAbortSignal
andwireAbortSignal
helpers to provide interoperability withAbortController
andAbortSignal
primitives. - Provide a utility function to easily run
async
logic