Dynamic variables in Node.js
For those of you unfamiliar with it, Common Lisp has this concept of "dynamic variables", which are nothing more than global variables with dynamic scope; but what does it mean for a variable to have dynamic scope? That you can bind a new a value to it, and all the subsequent accesses to that variable (within the scope of the binding operation), will return that new value instead of the previous one. Let's take a look at an example, as I hope it will make things a little bit easier to understand (the asterisks around a variable name indeed mark the variable as dynamic / special):
Step by step explanation:
- *x* was initially bound to 5, so when we access it we get 5 back -- all good
- We call FOO, which all it does is returning the value of *x*, and without much surprise we get 5 back -- so all good
- We call BAR, which binds *x* to 42 before calling FOO and returning its return value, and interestingly enough we now get back 42 instead of 5 -- there it is the dynamic scope I was talking about earlier
- Lastly, we access *x* and we get 5 back -- we are outside BAR's new binding scope, so the value of *x* has been restored to its previous value
Let's go back the definition that I gave earlier: global variables, with dynamic scope. I know that the first rule of global variables is: "thou shalt not use global variables", but it's just that sometimes they appear to be right tool for the job, especially in the context of Web applications; think about the following use cases:
- getting a hold of the currently logged in user
- automatically adding the content of the X-Request-Id header to each log trace
- querying the right database, based on the logged in user's tenant
How would you implement these? Either you shove all this information up into a context object, and pass it around, everywhere; or maybe you forget about all the bad things you read about global variables, and consciously and carefully agree to use them where it really matters, where it really makes a difference.
Anyways, enough talking about global variables, I do not want this to be an essay about its pros or cons (I am sure the Internet is full of material and opinions about it); instead, let's go back to our original goal: trying to implement dynamic variables in Node.js.
It all begins with the definition of a type representing a bag of bindings (we are going to wrap the standard Map type for this):
(Don't worry about that ugly syntax for now, we will deal with it later)
Getting a binding (i.e. getting the value bound to a variable), should be as easy as calling Map.prototype.get (plus some additional logic to validate user input):
First we check that the name of the variable is indeed valid, then we confirm that a binding for that variable actually exists, and finally we return the value bound to that variable. Let's play with it an confirm it's all working fine:
Lastly, to set new bindings, we will go about and create a new Binding object and initialize it with the existing bindings and the newly defined ones...merged together:
Again, let's can play with this to confirm that it's all working as expected:
All good, great! Before we dive into the details of the implementation of dynamic variables, let's first implement a couple of functions which will come in handy down the line. The first one is to simplify the syntax for creating new bindings; we don't want users to specify new bindings via nested lists (i.e. [['a', 1], ['b', 2]]); instead, we would like them to use a flattened list instead (i.e. ['a', 1, 'b', 2]):
Nothing crazy about this: we first confirm that the number of bindings is indeed even, and then wrap every pair of adjacent elements into a nested list. Let's give it a go:
Perfect! The second utility function might look a bit cryptic at first, simply because I am yet to show you what is the problem that it tries to solve, but hopefully soon it will all make more sense. We want to feed it with a list of elements representing a flattened list of bindings followed by a callback function, and we expect it to return a list whose first element is a list of key-value pairs created from the list of flattened bindings, and the second is the given callback function:
Let's test it out:
Alright, it's all working as expected, and with all of this defined and taken care for, it's time we took a look at a possible implementation for a dynamic environment, i.e. environment getting a hold of a bunch of dynamic variables.
The biggest challenge in implementing dynamic variables in Node.js is figuring out a way to persist state across changes of asynchronous context: you set *x* to a 5, invoke setTimeout, and when the callback is invoked you expect *x* to still be bound to 5. Similarly, if two asynchronous operations happen to re-bind the same dynamic variable, you don't any of them to step on each others toes.
Luckily for us, the Node.js core team has been working on this problem for quite some time now, and you can see the result of their effort in the async_hooks module. I am not going to bore you with its implementation details (mostly because I am not familiar with it myself), but for what we are trying to achieve here, all we need to know is that:
- Each piece of running code (user code), can have an ID attached, identifying its asynchronous execution context
- Each piece of running code (user code), can have another ID attached, identifying the asynchronous context that triggered, directly or indirectly, the current one (i.e. if you create three nested promises, each callback, when executed, will probably have a different asyncId value but same triggerAsyncId one)
- There is a low-level API, createHooks, that can be used to get notified when an asynchronous execution context is created or destroyed; with it, one could think of attaching some payload to the current execution context, and then expose another API for user code to access it
- There is a high-level API, AsyncLocalStorage, that shields the user from all the above complexity, and offers a simple way of running user code with a given piece of payload attached to the current execution context
It goes without saying it that AsyncLocalStorage is what we will use to implement our dynamic environment:
- Getting a binding translates to getting a hold of the current execution context's payload (i.e. the bindings), and returning whichever value is currently bound to the given variable name
- Setting a binding translates to creating a new set of bindings, attaching it to the current execution context, and running user code within it -- old bindings will be automatically restored after user code (synchronous or asynchronous) has finished running
Alright, let's get our hands dirty. Let's start by creating a new type for the dynamic environment:
Here, all we do, is creating the asynchronous context object (i.e. an instance of AsyncLocalStorage), and then initialize it with some user defined bindings (e.g. 'a', 1, 'b', 2). Let's give it a go to see what happens when we call the constructor (note: ctx.getStore() is how you access the payload of the current asynchronous context):
Let's now define a method to get the value of a specific binding (note how Bindings, our previously defined type, is doing all the heavy lifting here):
The last piece of the puzzle, is a mean of setting a new binding (or bindings), and run some user code within the scope of these new bindings; thanks to Bindings, AsyncLocalStorage, and the cryptic parseDynamicEnvironmentSetArguments I showed you before, this could not have been any easier to implement:
First we parse function arguments into key-value pairs and the callback inside of which the new bindings will be active; then we create a new Bindings object merging new bindings with any existing ones; lastly we tell AsyncLocalStorage to do its magic (i.e. attach new bindings to the execution context, and run user code). Let's try this out, and see if it works or not:
It seems like it is indeed working; but what if we added some asynchronous operations within the scope of the set call?
Still working, great! What about multiple asynchronous operations at the same time?
It works, and by the look of it it appears we were indeed able to implement "dynamic variables" in Node.js.
I added all the above into a new repository, dynamic_variables.js, so feel free to play with it in your REPLs and do let me know if anything breaks for you.
Also, it's worth remembering that async_hooks is still considered experimental, so its API might suddenly change with a new release of Node.js; well, that and the fact that the current implementation might still contain some nasty bugs that might cause your dynamic bindings to get lost across switches of execution context. This might not be a big deal if you were just to messing around with this, or if you were just planning to use this to enhance your logging capabilities; but if instead, you were planning anything more serious than that, like selecting the "right" database connection based on the logged-in user's tenant, then I would strongly recommend that you tested as many execution paths as possible and confirmed that no binding got lost in the process. You know, it works...until it doesn't!
PS. For educational purposes, here I am going to show you a different implementation of a dynamic environment, one that does not use AsyncLocalStorage to keep track of re-binds (it does that with a stack of active bindings) and because of that, one that most surely is going to fail the expectations in case of multiple nested, asynchronous, re-binds:
var UnsafeDynamicEnvironment = function (...flatBindings) {
this.snapshots = [new Bindings(parseKVPairs(flatBindings))];
};
UnsafeDynamicEnvironment.prototype.get = function (name) {
return this._getActiveSnapshot().get(name);
};
UnsafeDynamicEnvironment.prototype.set = function (...args) {
const [kvpairs, body] = parseDynamicEnvironmentSetArguments(args);
const bindings = this._getActiveSnapshot().set(kvpairs);
return this._runWithBindings(bindings, body);
};
UnsafeDynamicEnvironment.prototype._getActiveSnapshot = function () {
return this.snapshots[this.snapshots.length - 1];
};
UnsafeDynamicEnvironment.prototype._runWithBindings = async function (bindings, body) {
this.snapshots.push(bindings);
try {
return await body();
} finally {
this.snapshots.pop();
}
};
> test(async () => {
var env = new UnsafeDynamicEnvironment("x", 5);
var foo = function () {
return env.get("x");
};
var bar = function () {
return env.set("x", 42, () => foo());
};
assert.equal(env.get("x"), 5);
assert.equal(foo(), 5);
assert.equal(await bar(), 42);
assert.equal(env.get("x"), 5);
});
Promise { <pending> }
A-OK!!!
> test(async () => {
var env = new UnsafeDynamicEnvironment("x", 5);
var foo = function () {
return env.get("x");
};
var bar = function () {
return env.set("x", 42, () => {
return new Promise((resolve) => {
setTimeout(() => resolve(foo()), 2000);
});
});
};
assert.equal(env.get("x"), 5);
assert.equal(foo(), 5);
assert.equal(await bar(), 42);
assert.equal(env.get("x"), 5);
});
Promise { <pending> }
A-OK!!!
> test(async () => {
var env = new UnsafeDynamicEnvironment("x", 5);
var foo = function () {
return env.get("x");
};
var bar = function () {
return env.set("x", 42, () => {
return Promise.all([
foo(),
env.set(
"x",
52,
() =>
new Promise((resolve) => {
setTimeout(() => resolve(foo()), 1000);
})
),
env.set(
"x",
72,
() =>
new Promise((resolve) => {
setTimeout(() => resolve(foo()), 2000);
})
),
]);
});
};
assert.equal(env.get("x"), 5);
assert.equal(foo(), 5);
assert.deepEqual(await bar(), [42, 52, 72]);
assert.equal(env.get("x"), 5);
});
Promise { <pending> }
AssertionError [ERR_ASSERTION]: Expected values to be loosely deep-equal:
[
42,
72,
52
]
should loosely deep-equal
[
42,
52,
72
]
at repl:34:14
at async test (repl:3:9) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: [Array],
expected: [Array],
operator: 'deepEqual'
}