-
Notifications
You must be signed in to change notification settings - Fork 209
Description
With AsyncWrap taking shape, it feels like it could be a good time to determine how CLS (or a CLS-like module that uses AsyncWrap) should work with promises.
After delving into how promises and CLS interact, I've come to the conclusion that there are 3 different ways in which a CLS/Promise shim can work.
All three approaches have their own logic, and they're all incompatible with each other.
It's not clear which is the "correct" way. The behaviour of native promises with CLS (through the shim provided by async-listener) follows one convention, cls-q and cls-bluebird follow another.
The 3 conventions
Here are the 3 different approaches:
Convention 1: Callback style
This is the behavior of native JS Promises.
The context at the end of the last .then()
callback is maintained for the next .then()
callback. Where and when the .then()
is added to the promise chain is irrelevant.
For CLS purposes, the following are treated the same:
fs.readFile('foo.txt', function(text) {
console.log(text);
});
fs.readFile('foo.txt').then(function(text) {
console.log(text);
});
i.e. promises are essentially sugar for callbacks, rather than a distinct syntax with different behavior.
If the code inside a .then()
callback loses CLS context (due to using a queue or similar), then the shim would NOT correct this.
On the positive side, it allows a CLS context to be created within a .then()
callback and the rest of the promise chain that follows runs within that context. This could be useful e.g. for middleware.
Promise.resolve().then(function() {
return new Promise(function(resolve) {
ns.run(function() {
ns.set('foo', 123);
resolve();
});
});
}).then(function() {
console.log(ns.get('foo')); // prints 123
});
Convention 2: Follow promise chain
CLS context is set at the time of the promise's creation. Any promises which chain on from another promise inherit the same context.
This is the same as (1) except:
- If a
.then()
callback loses context, context is restored for the next.then()
callback - If a new CLS context is created within a
.then()
callback, it is NOT maintained for the next.then()
in the promise chain
ns.run(function() {
ns.set('foo', 123);
Promise.resolve().then(function() {
return loseContext(); // returns a promise, but loses CLS context
}).then(function() {
// original CLS context has been restored
console.log(ns.get('foo')); // prints 123
});
});
var promise;
ns.run(function() {
ns.set('foo', 123);
promise = Promise.resolve();
});
ns.run(function() {
ns.set('foo', 456);
promise.then(function() {
console.log(ns.get('foo')); // prints 123
});
});
Convention 3: Listener attachment context
CLS context for execution of .then()
callback is defined at time .then()
is called. This is not necessarily the same context as the previous promise in the chain.
Similarly to (2), if a .then()
callback loses context, this doesn't affect context for the next .then()
in the chain.
This appears to be the convention followed by cls-q and cls-bluebird.
var promise;
ns.run(function() {
ns.set('foo', 123);
promise = Promise.resolve();
});
ns.run(function() {
ns.set('foo', 456);
promise.then(function() {
console.log(ns.get('foo')); // prints 456
});
});
Difference between the three
The following code demonstrates the difference between the 3 conventions. It will log "This Promise implementation follows convention X", where X depends on which approach the promise shim takes.
var promise;
ns.run(function() {
ns.set('test', 2);
promise = new Promise(function(resolve) {
ns.run(function() {
ns.set('test', 1);
resolve();
});
});
});
ns.run(function() {
ns.set('test', 3);
promise.then(function() {
console.log('This Promise implementation follows convention ' + ns.get('test'));
});
});
NB With native JS promises you get "This Promise implementation follows convention 1". With cls-q or cls-bluebird you get "This Promise implementation follows convention 3".
Which way is best?
I think this is debatable. It depends on how you conceptualize promises and the control flow they represent.
Convention 1 is the simplest and isn't opinionated about what a promise control flow represents.
Native JS Promises follow this convention, so there's an argument other promise shims should follow the same convention to avoid confusion.
This doesn't cover the common use case of patching where a library like redis
loses CLS context within it. However, there's a strong separation of concerns argument that a shim for a promise library should just shim the promise library. If another library loses CLS context, then that library should be shimmed. i.e. solve the problem that redis
loses context with cls-redis
not cls-bluebird
!
Convention 2 conceptualizes a promise chain as a set of connected actions.
Imagine multiple tasks running in parallel, each composed of multiple steps e.g. read a file, transform it, write it out again. Each task run is represented by a promise chain.
Now if you want to add an extra step to each of the tasks (e.g. notify a server when task is done), you'd add an extra .then()
to the end of the promise chain for each task. You would expect each callback to run in the CLS context for that task.
Convention 3 conceptualizes a promise chain as a queue.
Imagine a resource which can only be accessed by one process at a time. The queue for access is represented by a promise. When a process finishes accessing the resource, it resolves the promise and the next in the queue (next promise in the chain) then starts up. If you want access to the resource, you add a .then()
to the promise chain.
If a running task (e.g. serving an HTTP request), gets the resource and then continues on with other things, you would expect promises chained on after accessing the resource to execute in the CLS context of the task, NOT the context of the preceding item in the resource queue.
function Resource() {
this.promise = Promise.resolve();
}
Resource.prototype.read = function() {
this.promise = this.promise.then(function() {
return fs.readFileAsync('/path/to/resource'); // NB returns promise
});
return this.promise;
};
var resource = new Resource();
// somewhere else in code
function doTheDo() {
return resource.read().then(function(resourceContent) {
// do something with the resource's content
});
}
Conclusion
I'm not pushing for one convention over another. I just thought it'd be useful to lay out what I think are the 3 different choices and their implications.
What I do suggest is that if there's some consensus on which convention is best, this be set out in a set of tests, so everyone can be sure that the cls-promise implementation they're using is compliant.
It would also clear up what's a bit of an ambiguity - there's been some confusion for example here: TimBeyer/cls-bluebird#1 (comment).
I've made a start on a test suite here: https://github.com/overlookmotel/cls-bluebird-test
Anyone have any thoughts on this?