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
Add a better way to handle transactions by binding them to models #1472
Conversation
Can one of the admins verify this patch? To accept patch and trigger a build add comment ".ok\W+to\W+test." |
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 love this idea 👏
I did a quick review of the proposed changes, see my comments below.
@raymondfeng @kjdelisle could you please take a more thorough look?
lib/datasource.js
Outdated
cb = cb || utils.createPromiseCallback(); | ||
if (connector.beginTransaction) { // Database | ||
Transaction.begin(transactionSource.connector, options, | ||
function(err, transaction) { |
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.
Can you extract this long callback into a standalone function, to make the code easier to read?
Transaction.begin(transactionSource.connector, options, onTransactionCreated);
// ...
function onTransactionCreated(err, transaction) {
// ...
}
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.
Yes!
lib/dao.js
Outdated
@@ -433,6 +439,7 @@ DataAccessObject.create = function(data, options, cb) { | |||
if (err) return cb(err); | |||
|
|||
if (connector.create.length === 4) { | |||
options = addTransaction(Model, options); | |||
connector.create(modelName, obj.constructor._forDB(context.data), options, createCallback); | |||
} else { | |||
connector.create(modelName, obj.constructor._forDB(context.data), createCallback); |
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.
When the connector does not support transactions, but the options
or Model properties indicate that a transaction should be used, then I think we should throw an error. Otherwise our users will think their code is wrapped in a transaction while it is not. This applies to all DAO methods.
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.
Model.getDataSource().isTransaction / transaction
will only be set if transactions are supported and dataSource.transaction(execute)
was used. So I don't think there is a need for exceptions?
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.
My concern is that the connection between the fact whether connector supports transactions and the number of argument of CRUD methods like connector.create
is not obvious and easy to unintentionally break in the future. I personally prefer a defensive approach - if we assume that transactions are enabled only when connectors support options
argument, then it's better to capture/assert that assumption.
For example:
options = addTransaction(Model, options);
if (connector.create.length === 4) {
connector.create(modelName, ..., options, createCallback);
} else if (options.transaction) {
throw new Error('The connector does not support create() within a transaction');
} else {
connector.create(modelName, ..., createCallback);
}
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, that makes sense. I could easily move that to addTransaction()
and call the function handleTransaction()
instead, so there wouldn't any replication of code. The name of the calling function (create
) in this example) could be passed to it as a 3rd argument.
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've just addressed this now in e3d47d8
Please have a look and let me know your thoughts.
lib/datasource.js
Outdated
|
||
return transaction; | ||
return connector.transaction ? transactionSource : cb.promise; |
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.
Uff, a return type that allows both a transaction and a promise is pretty confusing to me. Are we expecting all users of ds.transaction
to check what has been returned? I agree backwards compatibility is important, can we perhaps use the number of arguments to decide whether this function was called in the old way (and the transaction should be returned), or in the new way (and the promise should be returned)?
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.
Yes, that's definitely possible and makes more sense. I will get on it.
Can one of the admins verify this patch? |
2 similar comments
Can one of the admins verify this patch? |
Can one of the admins verify this patch? |
@bajtos I've addressed your feedback and pushed a new version now. |
I've worked some more on this now, and fully decoupled the dealing with transient/memory sources VS database sources from the handling of |
Oh and I forgot to mention: I optimised the generation of these 'slave-models' that are bound to the transaction object by the use of getters: Only the models that are actually accessed are now created as bound versions through |
I've further improved the code to handle all possible combinations of arguments and data-sources, and have improved the documentation to better describe these. I've also switched to using // Reuse TransactionMixin.beginTransaction() through a mock model that
// returns the connector.
// TODO: Move timeout/id handling from TransactionMixin.beginTransaction()
// to loopback-connector's Transaction class instead? I consider the code ready again for review now. |
lib/dao.js
Outdated
if (dataSource.connector[method].length < numArguments) { | ||
if (opts.transaction) { | ||
throw new Error( | ||
g.f('The connector does not support {{method}} within a transaction', method)); |
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 will most likely crash the process on an unhandled exception. Can we report the error via the callback please?
It may be easier (and probably more robust too) to change this method to take care of method invocation to.
Here is an example to illustrate my idea:
function invokeConnectorMethod(method, Model, args, options, cb) {
const dataSource = Model.getDataSource();
const numArguments = args.length + 3;
options = // add transaction if needed
const optionsSupported = dataSource.connector[method].length >= numArguments;
if (options.transaction && !optionsSupported) {
return cb(new Error(/*...*/));
}
const connector = dataSource.connector;
const fullArgs = [Model.modelName].concat(args);
if (optionsSupported) fullArgs.push(options);
fullArgs.push(cb);
connector[method].apply(connector, fullArgs);
}
Thoughts?
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're probably right! I did it this way because of your suggestion to throw an Exception : )
I will look into this now, but the reason why we probably can't do this invokeConnectorMethod()
that simply is because of irregular method signatures, see https://github.com/lehni/loopback-datasource-juggler/blob/e3d47d8ebb1b77e77ea638e10a512dfadb0aa066/lib/dao.js#L2473-L2475
But we could of course add this as an exception directly in invokeConnectorMethod()
?
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.
Implemented in 7d41a8f
@slnode ok to test |
I found this test that's doing a very minimal test of the transaction feature: https://github.com/strongloop/loopback-datasource-juggler/blob/2ab4a26396691949627f582998153bc10ea3470d/test/schema.test.js#L36-L63 IMO, we should ideally test all DAO methods to verify that they correctly propagate the transaction. I am proposing to create a new file Could you please rebase your patch on top of the latest master? I added support for code coverage via |
lib/datasource.js
Outdated
result.then(function() { done(); }, done); | ||
} else if (execute.length < 2) { | ||
// execute didn't return a thenable and doesn't receive a callback. | ||
// It must executed synchronously, so call done() now. |
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.
Minor nitpick:
// It must've executed synchronously, so call done now.
or
// It must have executed synchronously, so call done now.
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.
Addressed!
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.
IMO, we should ideally test all DAO methods to verify that they correctly propagate the transaction. I am proposing to create a new file test/transactions.test.js and place all new tests there. Let's keep the existing test unchanged as a guard for backwards compatibility.
+1
IMO, The *OrCreate
helper methods need test coverage to ensure that they leverage this functionality as intended.
That and a single nitpick with one of the comments, and I'm more than satisfied.
Excellent pull request. 💯
Forgot to add: |
@kjdelisle thanks for the feedback and the kind words! I'll get on it shortly. In the meantime, I'd like to suggest that we get these two PRs merged, so that my code here can rely on that being present for timeouts to work, instead of the dirty trick with |
@bajtos, @kjdelisle regarding tests: I believe that testing the functionality of actual transaction handling os the business of |
@bajtos, @kjdelisle I've added some tests now. Once #1484 and loopbackio/loopback-connector#116 land, I'll add a test for timeouts also. But I think generally, this is about how much I think we should test here. The rest really belongs to |
So far, we were keeping the shared test suite here in juggler - see test/persistence-hooks.suite.js for an example. Connectors then include these test files in their test suite, see e.g. mongodb connector. I guess the situation is a bit tricky for transactions, because the only built-in connector ( @kjdelisle what's your take on this? |
lib/datasource.js
Outdated
get: function() { | ||
// Delete getter so copyModel() can redefine slaveModels[name]. | ||
delete slaveModels[name]; | ||
return dataSource.copyModel.call(transaction, masterModels[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.
I find this suspicious - we delete slaveModels[name]
but don't set any new value. Should we store the result of dataSource.copyModel
to slaveModels[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.
dataSource.copyModel()
sets the new value by itself. I need to delete it so that it can do that, because if it's still there, it wouldn't override. That's part of the caching mechanism, and works well.
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 could store it additionally for clarity, but it's not required...
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.
Sounds good. Is there any test verifying this? (I.e. accessing the same slave model multiple times?)
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.
That's a good point! I will add a test for it.
* Add documentation for higher-level transaction API See loopbackio/loopback-datasource-juggler#1472 * Fix * Fix
* Add documentation for higher-level transaction API See loopbackio/loopback-datasource-juggler#1472 * Fix * Fix
@lehni Is there any way to get to the actual transaction from inside e.g let instance = Model.findOne(); // instance I had loaded previously
await app.dataSources.db.transaction(async (models, cb, tx) => {
const {MyModel} = models
let target = await MyModel.create({foo: 'bar'})
// I already have an instance but it isn't bound to the tx
instance.updateAttributes({ targetId: target.id }, { transaction: tx});
}) |
I agree, this makes a lot of sense. I would suggest to add it as the optional 2nd parameter before |
@zbarbuto I've started to look into this now. I see a few issues here currently that I'm not quite sure how to resolve yet:
@bajtos, @zbarbuto, @kjdelisle what do you think we should do here? |
We are pretty strict about backwards compatibility. If a code working with the latest version published to npmjs.org stops working after upgrade, then such changes must be released as semver-major version. Unfortunately, releasing a new major version of LoopBack is requires non-trivial amount of work which we are not willing to make now, considering the coming Beta release of https://github.com/strongloop/loopback-next (which we are planning to release as LoopBack 4.0).
I am little bit confused. All CRUD methods ( IMO, we should continue using the convention of passing the In which case, if I understand the problem correctly, we need to figure out how deal with two different kinds of "transaction" objects we can have in our app. I think Sorry if my comment is too terse a cryptic, I am short of time :( |
What about if the proxy db.transaction(async models => {
let tx = models.getTransaction();
await instannce.updateAttributes(update, { transaction: tx });
}) |
Hmm that feels like a bit like a hack, no? |
Can’t really think of any other way unless a new method gets added to db with the signature that has the extra tx argument passed to the callback. But that’s just as hack-y imo. In any case I think it probably warrants its issue as it’s been a bit of a pain point for me when trying to use this (have to re-findById every instance I use and can’t pass the tx out to other helper functions, have to pass the whole |
Alternative (still hack but backwards compatible): models.models = models
models.transaction = transaction Allowing: db.transaction(({ models, transaction}, cb) => {
// ...
}) |
Yeah I was thinking of that too... I don't think it's a problem to insert a new 2nd argument since the feature itself just rolled out. Adoption is probably at 0.0001% right now : ) But what about the other question regarding the |
I think that is more a case of documentation. If you do not pass in an // model was retrieved earlier outside the tx
const transactionCtx = await db.transaction();
const { transaction, models } = transactionCtx;
await model.updateAttributes(update, { context: transactionCtx.transaction })
await models.Foo.find();
await transactionCtx.commit(); Although I must admit looking at this it's a bit clumsy - since you're passing in transactions in some places and not in others, based on whether or not the model was retrieved using the proxy. Perhaps a better option would be to provide some method of binding existing model instances to the transaction so they behave like ones that were fetched from the proxy. Something like: // model was retrieved earlier outside the tx
const transaction = await db.transaction();
const { models } = transaction;
transaction.bindToModel(model);
await model.updateAttributes(update) // is now part of the tx
await models.Foo.find();
await transaction.commit(); That way the first few lines of your tx execution are binding your models to the tx and then the rest behaves as-is. |
I somehow didn't see @bajtos' reply earlier. If compatibility must remain, although the docs here don't actually mention this 2nd let ctx = Object.create(models);
ctx.models = models;
ctx.transaction = transaction; But I don't think this is clean though... I think As for the two suggestions by @zbarbuto, neither of those are easily feasible with the current code-base, and I don't have the time to investigate them: The 1st suggestion doesn't work since The 2nd suggestion isn't possible either, because binding can't be changed on existing models without breaking things elsewhere, since in order to change this binding, we need to change the model's data-source (hence these bound slave models). @bajtos to clarify: The db methods only ever receive the "internal" transaction object. The wrapper object is created to pretend to be a data-source that is in fact bound to the transaction, but inherits from the real data-source. I'm not explaining this well, but I'm also not the author of this approach. It was in place in the original I've called the reference to the internal transaction |
A last option, and perhaps the most elegant one, could be to make every const transaction = await dataSource.transaction();
await instance.updateAttributes(update, { tx: transaction }); This transaction objet would then also need a special getter for But then we face the problem again that the "internal" transaction object is defined by |
@zbarbuto would this workaround work for? I haven't tested it, but it should work: dataSource.transaction(async models => {
let tx = models[Object.keys(models)[0]].getDataSource().currentTransaction;
await instannce.updateAttributes(update, { transaction: tx });
}) |
Thanks for the workaround. Is it right right to get the transaction directly from the dataSource? What if you have multiple concurrent transactions happening asynchronously - what is |
Since you're retrieving the model from the I hope this clarifies it. |
@bajtos @zbarbuto to clarify my rambling above from yesterday: We can speak of the This all makes sense I think, the only part in this that I don't think is elegant currently is that this I can look into creating a And the reason why I didn't call the Changing this I wouldn't consider breaking because I hope this clears up the discussion... |
Great discussion! I am afraid I don't have bandwidth to follow in details, I prefer to leave it up to you two to figure this out. From my limited understanding of the problem and your proposals, My only concern is about a possible name collision (what if the app has a model called |
Yeah naming collisions was exactly why I suggested a getter in the first place. |
In that case I think I prefer the getter actually, since accessing underscored properties form the outside always feels like I'm doing something I'm not supposed to be doing : / But regarding naming collisions: If there was a case of a collision I think we'd simply not expose the transaction and give the |
Some more options
I quite like the last option as it is the cleanest... I'm also fine with the first, depending on what @bajtos thinks... |
Well, we are using
+1 Regarding |
@bajtos what about suggestion 2, the use of |
I find overloading
I don't have any strong opinion, please pick one that looks best to you. |
Let's see. What feels the best? @zbarbuto, any suggestions? dataSource.executeTransaction(async (models, transaction) => {
await outsideInstance.updateAttributes(update, {transaction});
}) dataSource.runTransaction(async (models, transaction) => {
await outsideInstance.updateAttributes(update, {transaction});
}) dataSource.runInTransaction(async (models, transaction) => {
await outsideInstance.updateAttributes(update, {transaction});
}) Or should we then also go with the idea of the dataSource.executeTransaction(async ({transaction}) => {
await outsideInstance.updateAttributes(update, {transaction});
}) dataSource.runTransaction(async ({transaction}) => {
await outsideInstance.updateAttributes(update, {transaction});
}) dataSource.runInTransaction(async ({transaction}) => {
await outsideInstance.updateAttributes(update, {transaction});
}) |
My issue with adding another method is that it sort of just adds un-needed method bloat to the datasource object. You have
Doesn't seem fair that if you want to use a My vote goes to leaving the method named as-is but appending db.transaction(async context /* or 'execution' */ => {
const tx = context.getTransaction();
const { User, Account } = context;
// ...
}) |
Ok but in that case I'll suggest doing this: We add Then you can use it like this: db.transaction(async ({models, transaction}) => {
const { User, Account } = models
// ...
}) And we're remain compatible as long as people don't deal with a model called db.transaction(async (models) => {
const { User, Account } = models
// ...
}) After a while, we could deprecate this access on I really think this is fine, as this code has barely been out, and we'll remove the documentation that mentions the @bajtos do you approve? |
@lehni I thought the decision was to go for a
If you really want to allow the |
No decision has been made yet. I liked the separate method best,
|
Description
Based on the way Objection.js is handling transactions by binding them to models, I was looking for a way to do the same in LoopBack. At first I thought I would have to make heavy use of
Proxy
objects, but luckily it turned out that there was already a mechanism in place, but only useable formemory
andtransient
datasources:https://github.com/strongloop/loopback-datasource-juggler/blob/6c6df15286400e0dfbc2a458e0a074acc1abeecd/lib/datasource.js#L2068-L2101
It works by making use of
DataSource
'scopyModel()
which creates a slave model bound to a given data source.This PR extends this existing infrastructure and adds support for database transactions through the use of
beginTransaction()
andTransaction.commit()
/Transaction.rollback()
.Below the documentation I wrote about its functioning.
I believe this is pretty complete and sturdy
, but I need to write unit tests for it still.I couldn't find any unit tests forbeginTransaction()
andTransaction.commit()
/Transaction.rollback()
, so I think I could use some help with that...And finally, an example of how this can be used in ES6 syntax with
async
/await
:Related issues
Checklist
guide