-
Notifications
You must be signed in to change notification settings - Fork 598
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
Spanner overlapping transactions don't fail when they should? #2118
Comments
OK. I've discovered that in if (self.id) {
reqOpts.transaction = { id: self.id };
}
function makeRequest(resumeToken) { Adding those 3 lines partly fixes the problem. I no longer have it blasting out writes (aka, Spanner can do the right thing), but I do not get an error in the way that I should. Things just crash... That could be my code though. New output below:
|
OK -- after further digging, our retry logic appears to be broken. If I change the
Did the spec say that we should retry conflicts like this? I have a hard time buying that the right thing is to just re-run the same mutations we had queued up. For example, what if the change was adding $100 to a bank balance? Wouldn't we want to re-run all of the logic (get balance, add $100, save new balance) and not just re-run the one mutation (set balance = $200) ? To clarify, I think if we want retries to work as expected, we need to have all of the code that would need to be retried in a chunk -- not just the reads/writes that were called on the mutation ( database.runInTransaction(function (txn) {
txn.read(query).then(function (results) {
txn.insert(....);
return txn.read(otherQuery);
}).then(function () {
txn.insert(...);
return txn.commit();
});
}); And on retry, we take that entire callback and run it again. With the example that I have above with promises... I have no idea how to pick out only the parts that involve And we may want to try to stop people from nesting transactions or ensure that we don't retry nested transactions. If we retry something that has two transactions that conflict, like my example, not only will the second to commit always fail, but we may end with the first one committing several times (which, if it "doubles a bank balance") could be a very bad thing. |
cc @vkedia for any thoughts or prior art on the correct way to handle this scenario. |
@jgeewax Yes you are right that we should not just retry the buffered mutations. We need to retry all the operations that were done on the transactions. So essentially we should just reinvoke the function that was passed to runInTransaction. |
@stephenplusplus As JJ suggested we need to retry the whole callback. Its similar to what we do in other client libraries. Do you have any specific questions around that? |
@stephenplusplus Also to begin with lets first fix the bug about transaction id not being passed around and then we can tackle retries. |
FYI @lukesneeringer @jmuk |
@jgeewax @stephenplusplus |
(cc @callmehiphop for these q's as he is the original implementer) |
I think there was a fumble on our end, but my intention was never for this method to behave how the docs describe promise usage. Ideally the user would still pass in a callback but the callback itself would return a promise, making the original method chainable var promise = database.runTransaction(function(transaction) {
return transaction.run('SELECT * FROM Singers').then(function() {
// do stuff
return transaction.commit();
});
});
promise.then(function() {
// all good!
}, function(err) {
// oh noes!
}); We had originally talked about trying to intercept the promise chain and retrying all the callbacks up until commit is called (hence why I think the doc examples are written this way), but ultimately I decided against it due to that behavior being unpredictable. |
Ah I see. So how do we tackly this now? Do we change the method to always take in a callback? |
@vkedia yeah, I think just ensuring the transaction ID is being passed in properly and updating the docs/promise handling of |
That seems reasonable to me. |
@callmehiphop @danoscarmike Let us proceed with this for now. I don't like having a client out there which can possible corrupt user data for too long. Let us do the following:
|
@callmehiphop @danoscarmike Any updates on this? |
/cc @lukesneeringer |
I spoke with @callmehiphop elsewhere and he said he will have this done by the end of the day. |
@vkedia @stephenplusplus @lukesneeringer @jgeewax so our typical callback vs. promise convention is pretty simple - omit the callback and you shall receive a promise. I think this is the first API that really complicates this approach. Consider the following example database.runTransaction()
.then(transaction => {
return Promise.all([
transaction,
transaction.getRows('Singers')
]);
})
.then([transaction, rows] => {
// do some kind of mutation based on the rows
return transaction.commit();
})
.then(() => {
// do something async/unrelated to the transaction
}); My main issue is that in order to properly retry our transaction here, we would need to capture all functions passed to the promise before That being said, if we were to continue to try and let both callbacks and promises live together within a single method, we need a different way of letting the user tell us that they want promises. My current suggestion is to let the user return a promise within the callback (like so) database.runTransaction((err, transaction) => {
if (err) return Promise.reject(err);
// do some transactional based stuff
return transaction.commit();
})
.then(() => {
// transaction is committed, so some other stuff now
}); However after some discussion in my PR, this also may be confusing for the users. So I would like to revisit this specific API since we don't have a precedent for this specific issue. An idea that we've been kicking around would basically be to not directly support promises in and out of this method, but only in.. This means the code would resemble the following database.runTransaction((err, transaction) => {
if (err) {
// an error occurred while trying to get/create a session + transaction
}
// do reads/writes/etc. here
// now the user has a choice between using a promise or a callback
// and while this function will be ran multiple times in the event of an aborted error
// commit will not resolve until either a success or final error occurs
transaction.commit()
.then(() => console.log('committed successfully!'))
.catch((err) => console.error(err));
}); At which point if a user doesn't feel that this is the way they want to use Promises, it would be fairly easy to wrap that into something like function runTransactions() {
return new Promise((resolve, reject) => {
database.runTransaction((err, transaction) => {
if (err) return reject(err);
// do transaction stuff
transaction.commit().then(resolve, reject);
});
});
} |
A quick "A or B" is: // A)
datastore
.runTransaction(function(err, transaction) {
if (err) {
// Could not start transaction
return Promise.reject(err)
}
// ...transaction stuff...
return transaction.commit()
})
.then(success)
.then(doSomethingElse)
.catch(handleErrors)
// B)
datastore.runTransaction(function(err, tranaction) {
if (err) {
// Could not start transaction
}
// ...transaction stuff...
transaction.commit()
.then(success)
.then(doSomethingElse)
.catch(handleErrors)
}) Gist: https://gist.github.com/stephenplusplus/3cb49529ee537c640a4670edf900e294 |
I think option B is confusing. IIUC what you are saying is that even though user may end up calling transaction.commit multiple times, only one of the returned Promise would ever complete? Do you return the same Promise object each time transaction.commit is called? |
I believe what would happen is if we received an aborted error and had to restart the transaction, we would never resolve the |
I am not sure its a good idea having all these unresolved Promises lying around. It is also very unintuitive for the user. |
That's the case for both A and B though. We have to re-initialize and run the entire function, which can create an unknown amount of resources and memory. |
But that memory will get freed up at some point. But if I understand correctly in approach B, those Promises will linger around forever holding whatever memory they are. |
@lukesneeringer |
I did take it for granted that we would be able to free those up, though I'm not sure how-- Dave could hopefully know if a solution exists. If one doesn't, then that absolutely shuts down option B. I think A is error-prone in two ways:
I think it's more clear to have the commit's resolution handled after the commit request, as opposed to using another chain of promises. |
As far as error handling concerned, why not omit the error handling in the callback. Even for errors like session not available, why not just bubble them up by rejecting the Promise returned by runTransaction. |
I believe that's because we don't know if the user is using promises until they return one from the callback. |
How about using a different method for Promise rather than overloading runTransaction? Or have runTransaction take in two callables, one for handling transaction and another for handling error? If users want to use Promise then they should just pass the first callable and not the second one? |
Assuming we worked out the memory control, can you elaborate on the disadvantages of option B? |
Actually thinking about this, the issue is not just with option B. Even in option A, transaction.commit() does return a Promise which the user is expected to return right back. What do we do with all those promises right now in case of retries. Do we resolve all of them? |
Release https://github.com/GoogleCloudPlatform/google-cloud-node/releases/tag/spanner-0.2.0 |
My understanding is that if you read a cell (say the
name
column from a specific row in anemployees
table) in two different transactions, then commit a change to that cell in one of them, then a subsequent commit to that same cell from the other transaction should fail. Is this correct?(I'm pulling this info from our docs, and making sure that these are not 'blind writes' by doing a read first -- see https://cloud.google.com/spanner/docs/transactions#rw_transaction_performance)
If so, our client library doesn't appear to do that...?
The output from this script looks totally successful:
I expected that
txn2
commit result to be a failure because the data changed out from under us... Is my understanding wrong? or the client library wrong? or a bug in my code?/cc @callmehiphop
The text was updated successfully, but these errors were encountered: