Knex is one of the most popular Query builders for NodeJS.
This can be attributed to its:
ease of use
highly customizable and modular architecture
great documentation
However, whenever I used knex, I always experienced a minor inconvenience when
handling knex transactions.
In order to create a transaction you have to issue the following command:
or without using a callback
The problem emerges when attempting to pass the trx object into other functions. I
frequently saw code like this in several firms that used knex:
This implies you’d have to send the trx object along to any other functions that could
use it. To complicate matters further, it was desirable in some circumstances to default
to the knex query builder whenever a transaction object was not supplied, therefore the
code had to include the vexing (trx || knex) phrase in every single place where a
query would be conducted.
I ideally intended to build a block of code by calling two functions: startTransaction
and endTransaction. Every query conducted inside this scope would have to be a part of
the created transaction.
Naive Implementation
The first naive approach would simply be to manually invoke a transaction by using
knex.raw.
This would have worked perfectly well if the system simply handled one request at a
time. In knex, by chaining commands to the transaction object we manage to associate
those commands with this transaction. A “Begin” command executed with knex.raw would
start a transaction throughout the whole connection, implying that a second request
might be made in the same context. This would result in an error (“WARNING: there is
already a transaction in progress”).
To resolve this issue, it appears that we will have to revert to the previous method of
utilizing the transaction object within the query. A far better idea would be to create
a transaction object when issuing the startTransaction command and store it into
the context storage of the request that triggered it. Whenever a query is executed, it
would check this storage in order to find out if such a transaction exists and is
active. If not, it would try to use the default knex instance to resolve the query.
However, there is a problem: NodeJS is singlethreaded and therefore does not retain a
context per request.
Enter Async Hooks
Version 8 of NodeJS introduced an experimental version of Async Hooks. The module helps
you monitor various “Asynchronous Resources” in the Node Ecosystem which represent
objects with associated callbacks (for example Promises, Timers, etc). In general, the
async resources have three states:
Creation
Callback execution
Destruction
An Async Resource may generate additional resources over its lifespan. Every
resource contains an Async ID that uniquely identifies it, as well as a Trigger ID which
is practically the Async ID of the resource that spawned it.
Async Resources
We may construct local storage for distinct async resources that share the same parent
by associating them together. Fortunately, the AsyncLocalStorage module in the
async-hooks package allows us to construct a shared context across such resources.
Async Storages for different Resources
The following code snippet adds a middleware to an ExpressJS server which creates a
unique local storage for every request.
From that point onwards every function called in that asynchronous storage (all
asynchronous resources sharing the same parent id), will have access to this same
instance of the localStorage variable.
We finally have found a way to overcome the lack of a local storage per request. The next
task would be to create the “startTransaction” and “endTransaction”
functionality.
The Context Handler
By utilizing async hooks we can now create the Context Handler: a module useful for
monitoring and editing context storage. Each entry in the storage has its own format
(kept simple for now):
The next step was creating the Context Handler class (could use dependency injection
to refer to the same instance everywhere or simply be a singleton).
The getMiddleware function should be called during ExpressJS setup so that it can
initialize a local context for each request. In the code presented above, we’ve already
created a very simple storage strategy for each request; that feature is pretty useful
on its own, but we’ll go even further.
Next in line is the addition of the startTransaction and endTransaction methods:
We finally have the methods required to start, commit or rollback a transaction. The
transaction object is itself stored in the context store. However, how will the knex
instance be able to access this transaction? We will try to overwrite the original knex
instance with a proxy. This proxy will trace calls to the database such as:
knex.raw()
knex.select().from(table)
knex(table).select(‘’)
and replace them with the expression that we used before: (trx||knex) (if there is a
transaction in the local storage, use this one instead of the knex instance)
Now that the Context Handler module is finally written we can include it into
the rest of our architeture by following this simple steps:
Add it as a middleware to our express server.
Alter the original knex object through it
Caveat
Obviously this work does not come with limitations. Since the
context storage is common across Async Resources with the same parent ids
trying to create different transactions (concurrently) into children scopes
will inadvertently cause transaction overwrites. For example:
Since all of the executeTransaction functions where called in the same scope
then they will share the same context storage. This means that whenever each
startTransaction is called then it will try to add a trx object in memory
(practically increasing the trx counter). Consequently, even though the code seems
to be using different transactions, it will just be utilizing one of them. I will try
to create a post on how to fix this issue and be able to initiate multiple transactions
concurrently on the same context. However until then you may use this software (or ideas
derived from it) to execute sequential transactions.