-
-
Notifications
You must be signed in to change notification settings - Fork 34
feature(automatic-transactions): Creates a decorator to make transactions easier for the developer #76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feature(automatic-transactions): Creates a decorator to make transactions easier for the developer #76
Conversation
f95c7c9
to
e3ef5c9
Compare
a2f82f6
to
0c403ec
Compare
test/req-transaction.test.js
Outdated
fastify.route({ | ||
method: 'GET', | ||
url: '/users', | ||
handler: (req, reply) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
add option { pg: { transact: true } }
What I think is missing is:
You can priobably use the existign |
4a10bac
to
467853a
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm posting this late but we went through this already anyway
index.js
Outdated
|
||
if (useTransaction) { | ||
fastify.addHook('preHandler', async (req, reply) => { | ||
await fastify.pg.transact(async (client, commit, done) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
get rid of this one
index.js
Outdated
}) | ||
}) | ||
fastify.addHook('onSend', async (req, reply) => { | ||
await fastify.pg.transact(async (client, commit, done) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
get rid of this one
index.js
Outdated
const useTransaction = routeOptions.options && routeOptions.options.useTransaction | ||
|
||
if (useTransaction) { | ||
fastify.addHook('preHandler', async (req, reply) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fastify.addHook('onRoute', routeOptions => {
const useTransaction = routeOptions.options && routeOptions.options.useTransaction
// - add the preHandler and onSend hooks as in https://github.com/fastify/fastify-rate-limit/blob/c73dfdb78f3012f56d0ec7e02f754f205260ac1d/index.js#L112-L118
// - in preHandler you: 1) grab a client from the pool, 2) start the transaction (with client.query('BEGIN')) and 3) decorate the request with the client so it's accessible within the handler
// - the handler is executed
// - in onSend you decide whether to commit or rollback the transaction (see discussion in issue comments to decide which logic to apply here
})
467853a
to
74a1f49
Compare
* Uses hooks to handle transactions outside the route handler code * preHandler does BEGIN * onSend does COMMIT * onError does ROLLBACK * useTransaction routeOption gives the developer opt-in for this feature - they have to explicitly ask * Tests cover the four possibilities, a passing and failing set of queries, called in both true and false states for useTransaction For DX: * Adds `--fix` to the linting to help automate indenting etc. * Adds a `testonly` script which doesn't drop out for linting (helpful when iterating quickly over tests). resolves fastify#75
74a1f49
to
3de300e
Compare
index.js
Outdated
const addHandler = (existingHandler, newHandler) => { | ||
if (Array.isArray(existingHandler)) { | ||
existingHandler.push(newHandler) | ||
} else if (typeof existingHandler === 'function') { | ||
existingHandler = [existingHandler, newHandler] | ||
} else { | ||
existingHandler = [newHandler] | ||
} | ||
|
||
return existingHandler | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this can be written in a nicer way without reassigning variables and returning early.
in general, choose between two implementations:
- immutable: return a new value to the caller without modifying the inputs
- mutable: mutate the input and don't return anything to the caller
in this case, because there's one case where you're forced to reassign the existing handler, only the first option is viable. stick to that
index.js
Outdated
@@ -102,6 +115,54 @@ function fastifyPostgres (fastify, options, next) { | |||
} | |||
} | |||
|
|||
fastify.addHook('onRoute', routeOptions => { | |||
const useTransaction = routeOptions.useTransaction || (routeOptions.options && routeOptions.options.useTransaction) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
are we allowing two different ways to require a transaction? why?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is a misunderstanding on my part about how Fastify works.
When I was using the following syntax I was using an options key
method: 'GET',
options: { useTransaction },
handler ...
But I think I could put the key directly into that object in which would negate the need for the extra conditions
I will change
index.js
Outdated
if (useTransaction) { | ||
// This will rollback the transaction if the handler fails at some point | ||
const onError = async (req, reply, error) => { | ||
req.transactionFailed = true |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
prefer to use a symbol so this plugin is the only one who has access to it
index.js
Outdated
try { | ||
await req.pg.query('ROLLBACK') | ||
} catch (err) { | ||
await req.pg.query('ROLLBACK') | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what does this do?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the best I could come up with in the response to the question 'what happens if rollback fails'
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if rollback fails and you try it again, it will most likely fail again. so retrying a rollback is not useful. rather, we should make sure that failing a rollback due to an error happened in the handler returns an error to the client which reflects the error that was thrown in the handler, whatever it was
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does Fastify already do that? ie. onError
intercepts an error that was already heading to the user via 500
mechanisms?
https://www.fastify.io/docs/latest/Hooks/#onerror
The only option would seem to be throwing a 'rollback failed'
Wondering what happens to unfinished transactions who's clients are released?
index.js
Outdated
const client = await pool.connect() | ||
req.pg = client |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why are we grabbing a client from the pool before knowing if we need a transaction? or actually, don't we already know, if we're executing this code, that we need a transaction so the next conditional is unnecessary?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, so we can run all of this conditionally?
I was thinking we would still need to get the client in any case, but now I see that was a mistaken thought. This also relates to #76 (comment) where I wanted to ensutre that I always released the client
It looks like the whole lot can go in to the conditional so I'll make that change
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if the route has no option to require the use of the transaction, all the plugin code should be a no-op basically
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In order to make this work I had to re-instate these lines from the earlier attempt which ensure a client is available on the req
:
if (!fastify.hasRequestDecorator('pg')) {
fastify.decorateRequest('pg', null)
fastify.addHook('onRequest', async (req, reply) => {
req.pg = fastify.pg
})
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
doesn't look like this code is in any way related to what you're trying to do here. if you need a client, you get it from the pool, but only if you need it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the original issue description pg
is added to the req
but if we only do that conditionally in useTransaction
then we don't have that available on the req
anymore. This makes the API inconsistent, ie, if I change useTransaction
from true to false I then also have to change from req.pg
to fastify.pg
in my code. My useTransaction=false tests failed and enabled me to catch this.
index.js
Outdated
await req.pg.query('COMMIT') | ||
} | ||
} catch (err) { | ||
if (useTransaction) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do we need this conditional? aren't we executing this code only if we already knew that we needed a transaction?
add-handler.js
Outdated
handlers = [newHandler] | ||
} | ||
|
||
return handlers |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove handlers variable and early return
index.js
Outdated
fastify.addHook('onRoute', routeOptions => { | ||
const useTransaction = routeOptions.useTransaction | ||
|
||
const transactionFailedSymbol = Symbol('transactionFailed') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pull this at the module root
index.js
Outdated
@@ -102,6 +104,57 @@ function fastifyPostgres (fastify, options, next) { | |||
} | |||
} | |||
|
|||
fastify.addHook('onRoute', routeOptions => { | |||
const useTransaction = routeOptions.useTransaction |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this should be as suggested in the issue { pg: { transact: true } }
index.js
Outdated
const preHandler = async (req, reply) => { | ||
const client = await pool.connect() | ||
req.pg = client | ||
await req.pg.query('BEGIN') | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is preHandler, let's define it before onError
index.js
Outdated
|
||
const preHandler = async (req, reply) => { | ||
const client = await pool.connect() | ||
req.pg = client |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
use decorateRequest
index.js
Outdated
const preHandler = async (req, reply) => { | ||
const client = await pool.connect() | ||
req.pg = client | ||
await req.pg.query('BEGIN') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
awaiting at the end of an async function is an anti pattern https://www.nearform.com/blog/javascript-promises-the-definitive-guide/
index.js
Outdated
const onError = async (req, reply, error) => { | ||
req[transactionFailedSymbol] = true | ||
|
||
try { | ||
await req.pg.query('ROLLBACK') | ||
} catch (err) { | ||
// await req.pg.query('ROLLBACK') | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
please check in a unit test where you mock req.pg.query what happens if sending the ROLLBACK query fails, because according to the fastify docs, the onError hook does not support sending an error to the done callback
const onError = async (req, reply, error) => { | |
req[transactionFailedSymbol] = true | |
try { | |
await req.pg.query('ROLLBACK') | |
} catch (err) { | |
// await req.pg.query('ROLLBACK') | |
} | |
} | |
const onError = (req, reply, error, done) => { | |
req[transactionFailedSymbol] = true | |
req.pg.query('ROLLBACK', done) | |
} |
index.js
Outdated
|
||
// This will commit the transaction (or rollback if that fails) and also always | ||
// release the client, regardless of error state or useTransaction value | ||
const onSend = async (req, reply, payload) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: we may not need the reply and payload args if we're not using them
index.js
Outdated
} catch (err) { | ||
await req.pg.query('ROLLBACK') | ||
} finally { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
no need to rollback if the commit failed. this keeps the same semantics as the transaction util defined above
index.js
Outdated
if (!fastify.hasRequestDecorator('pg')) { | ||
fastify.decorateRequest('pg', null) | ||
fastify.addHook('onRequest', async (req, reply) => { | ||
req.pg = fastify.pg | ||
}) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let remove this for the time being
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if I were to write this code, I would keep it like: (not 100% sure though)
if (!fastify.hasRequestDecorator('pg')) { | |
fastify.decorateRequest('pg', null) | |
fastify.addHook('onRequest', async (req, reply) => { | |
req.pg = fastify.pg | |
}) | |
} | |
if (!fastify.hasRequestDecorator('pg')) { | |
fastify.decorateRequest('pg', fastify.pg) | |
} |
0a75a81
to
ac5441b
Compare
Resolves fastify#75
ac5441b
to
90febc6
Compare
index.js
Outdated
return next(new Error(`pg client '${name}' is a reserved keyword`)) | ||
} else if (fastify.pg[name]) { | ||
return next(new Error(`request client '${name}' has already been registered`)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the error handling here should be done differently from how it's done in the plugin root. calling next with an Error was fine there, it is not here. Simply throw the error instead. Same at line 130.
index.js
Outdated
} | ||
} | ||
|
||
req.pg.query('BEGIN') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if the plugin is registered with a name, you have to use it everywhere. this will not work. same in all other places where you're accessing req.pg. all those accesses, in case a name is provided, should be req.pg[name]
index.js
Outdated
const client = await pool.connect() | ||
|
||
if (name) { | ||
if (client[name]) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can't apply the same logic that was applied before, it doesn't make much sense here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We resolved this in Slack. It makes sense to be defensive and avoid a namespace that matches a property on the client
object from pg.
index.js
Outdated
if (name) { | ||
if (client[name]) { | ||
return next(new Error(`pg client '${name}' is a reserved keyword`)) | ||
} else if (fastify.pg[name]) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this also doesn't make much sense, you must check against req.pg, not fastify.pg
52317fe
to
5aed915
Compare
As with the base fastify instance, the pg decoration will use namespaces. For each namespaced called to the main function, the namespace will be added to the req.pg object too, with similar checks The option to use a chosen namespace with the routeOptions is provided and we make sure that this namespace is used to extract the correct client from req.pg A test also covers this for queries wrapped in a route transaction Resolves fastify#75
f64fbd4
to
95623b9
Compare
index.js
Outdated
const preHandler = async (req, reply) => { | ||
const client = await pool.connect() | ||
|
||
if (name) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
are you sure about the namespacing logic? you must handle all combinations of:
- non namespaced / namespaced registration
- non namespaced / namespaced route transaction
to make an example of something that doesn't work correctly here, if you register with name foo
and enable the transaction with name bar
, this code should do nothing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good spot.
An error might be good in this case too, but I should also make a test that covers it.
Currently working through the test for duplicate namespaces that might have exposed something else, so I will work on this next.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if you register with name foo and enable the transaction with name bar, this code should do nothing.
I think this can be handled just by checking the req
for the correct namespace and if it doesn't exist throwing an error.
Either way, I think we should do this, so I will add an error and test for this
index.js
Outdated
if (req.pg) { | ||
throw new Error('request client has already been registered') | ||
} else { | ||
req.pg = client |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Must use Object.assign
here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In fact, this is caught by the previous line
Resolves fastify#75
We needed to avoid the unnecessary addition of handlers in the case where a name and transact value mismatched (different names, or true and a name, etc.) Resolves fastify#75
index.js
Outdated
} | ||
} | ||
|
||
extractRequestClient(req, transact).query('BEGIN') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you don't need this here, you can simply use client
test/req-initialization.test.js
Outdated
fastify.inject({ | ||
method: 'GET', | ||
url: '/' | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe this could be simply fastify.inject('/')
index.js
Outdated
@@ -52,6 +56,18 @@ function transact (fn, cb) { | |||
}) | |||
} | |||
|
|||
function extractRequestClient (req, transact) { | |||
if (transact.length) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
invert the condition here to return early, plus use a more obvious condition: typeof transact === 'string'
Resolves fastify#75
8bf6407
to
91fc85d
Compare
Hey everybody, We're ready to take this out of draft and get reviews from other users. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good work! This is missing docs
add-handler.js
Outdated
@@ -0,0 +1,12 @@ | |||
module.exports = (existingHandler, newHandler) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
use strict is missing.
Maybe it's better to move this file inside lib/
package.json
Outdated
@@ -5,6 +5,7 @@ | |||
"main": "index.js", | |||
"types": "index.d.ts", | |||
"scripts": { | |||
"testonly": "tap -J test/*.test.js && npm run test:typescript", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why this? Can you remove?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I found this useful because when I was developing I frequently wanted to see if I'd broken tests. When running that I didn't want something like indentation linting to stop the tests running.
Happy to remove, but that was my reasoning.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking good. Missing documentation.
Resolves fastify#75
Adds in a README section covering the transact option Resolves fastify#75
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lgtm
cc @voxpelli |
Resolves fastify#75 Co-authored-by: James Sumners <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, I did not use this plugin
in any of my project. But I do not want to see this blocked by pending reviewer
. So, I will approve the change.
Overall, when I scan through the code. It looks good.
Description
onRoute
handler.**
preHandler
: gets a client from the pool and decorates the request object with that client. Note that we use a client, not the db object that is atfastify.pg
.**
onError
: if the handler fails we rollback in this handler (and set a Boolean so we don't try to commit in onSend.**
onSend
: We commit here if the handler is successful and make sure the client is released to the pool.{ pg: { transact: true || 'string' } }
to decide whether to usereq.pg
orreq.pg[name]
in the request hooks. Note that this was easier to handle than the proposed API in the issue.Checklist
npm run test
andnpm run benchmark
and the Code of conduct
Questions
try/catch
is used in the handler is still outstandingResolves #75