-
Notifications
You must be signed in to change notification settings - Fork 100
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
Programming model for runTransaction is hard to use with promises. #249
Comments
This has been suggested before, but truthfully I can't remember what the reasoning was for shooting it down. The only tricky thing I see with this proposal would be continuing support for a callback mode. @stephenplusplus @googleapis/yoshi-nodejs thoughts? |
I recall we had promise support and had to ditch it because we can't replay a promise chain when retrying a transaction. |
^ Yeah this is the blocker I've run into when going down this path. This is something we're actively working on creating a cleaner, less error-prone, way to do that fits a bit better with promise based codebases. I'll report back with any findings, our current idea is to use a using/disposer pattern. |
I think what @majelbstoat is proposing is not trying to keep track of the entire promise chain, but allow the callback to return a promise. database.runTransaction(async (err, transaction) => {
return transaction.run(`SELECT * FROM Table`)
.then(([rows]) => transaction.commit());
}).then(() => {
// transaction finished
}); I know this has been pitched before and shot down |
Hmm, could you see if you can find those discussions? |
We also get same pain, but we used follow method to kinda make it compatible with async/await . exports.withTranscation = (fn) => {
return new Promise((res, rej) => {
database.runTransaction(async (err, transaction) => {
if (err) return rej(err);
let result;
try {
result = await fn(transaction);
} catch (error) {
return transaction.rollback((rollBackError) => {
if (rollBackError) {
logger.error({message: 'Rollback error occur', error: rollBackError});
}
return rej(error);
});
}
return transaction.commit((commitError) => {
if (commitError) {
logger.error({message: 'Commit error occur', error: commitError});
return rej(commitError);
}
return res(result);
});
});
});
}; Usage: const result = await withTranscation(async (transaction)=>{
// code
}) |
@callmehiphop: Yeah, I understand you might want to re-run that callback multiple times, which makes sense (and which I'm glad of!). But when it finally succeeds, getting the final result of it back would be very helpful. Async error handling inside the callback is still painful, but I can just make do with handling those manually myself for now. FWIW, this is basically what the golang SDK does, though |
@eric4545, thank you for sharing your code :) do you ever have problems with that if the inner callback runs multiple times due to retries or something? Like promise resolved after rejected, or similar? |
@majelbstoat, inner callback you mean the |
@eric4545 yes. As I understand it, the spanner client will under certain circumstances run that callback more than once. (In case of transaction conflicts, IIRC?) |
Yes, we did some load test to our system, and our APM show that the You are right, the spanner client will exec |
Typically we only retry
const retry = require('p-retry');
const Spanner = require('@google-cloud/spanner');
const ABORTED = 10;
const spanner = new Spanner();
const database = spanner.instance('my-instance').database('my-database');
function runTransaction(fn) {
return retry(async () => {
const [transaction] = await database.getTransaction();
try {
return await fn(transaction);
}
catch (e) {
if (e.code === ABORTED) throw e;
throw new retry.AbortError(e.message);
}
});
}
runTransaction(async (transaction) => {
const [rows] = await transaction.run('SELECT * FROM MyTable');
const data = rows.map(row => row.thing);
await transaction.commit();
return data;
}).then(data => {
// ...
}); |
That's really helpful, thanks, @callmehiphop. |
If it's helpful to anyone else struggling with this, here's a Typescriptified wrapper: import { Query, Row, Database as SpannerDatabase, Table, Transaction } from '@google-cloud/spanner'
import retry from 'p-retry'
const ABORTED = 10
type TransactionFn<T> = (transaction: Transaction) => Promise<T>
class Database {
private db: SpannerDatabase
constructor(db: SpannerDatabase) {
this.db = db
}
runTransaction = <T extends {}>(fn: TransactionFn<T>): Promise<T> => {
return retry(async () => {
const [transaction] = await this.db.getTransaction()
try {
return await fn(transaction)
} catch (e) {
if (e.code === ABORTED) throw e
throw new retry.AbortError(e.message)
}
})
}
run = (query: Query): Promise<[Row[], object]> => {
return this.db.run(query)
}
table = (name: string): Table => {
return this.db.table(name)
}
}
export default Database Used like this:
Which gives you type-safe transaction results. A reasonable update would be to allow it to take transaction options as an additional parameter. (This uses typescript types that i defined and submitted to DefinitelyTyped. Currently failing some of their strict lint cases, and probably slightly wrong in one or two places, but mostly usable anyway. Gist of them here: https://gist.github.com/majelbstoat/cfe80d665baec8631d25f10a7b23e917) |
Trying to make a version of this without retries but am finding some conflicting info on how to handle errors from I remember being told on a call with the Spanner team that we should be calling Is this correct or is the usage described here what I should follow [0]? [0] #103 (comment) |
Here's what we've cooked up thus far on this front, requires Bluebird: We're calling // Add disposer to getTransaction
function getTransaction(spannerConn) {
return spannerConn.getTransaction()
.disposer(async (dbTrx, trxActionResultPromise) => {
if (trxActionResultPromise.isRejected()) {
// Call in case of non-spanner related error being thrown
await dbTrx.rollback();
// Make sure failed txn gets closed
// Waiting on response about if we should actually do this
// https://github.com/googleapis/nodejs-spanner/issues/249#issuecomment-401637757
await dbTrx.end();
}
});
}
/*
* Background info: https://github.com/googleapis/nodejs-spanner/issues/249
*
* Promisified transaction helper
* Returns Promise with transaction result.
* Will *not* retry automatically on transient errors.
*
* @param {Database} spannerConn - Spanner db instance
* @param {Function} trxFunc - Function to run with db transaction as an argument to perform operations
*/
export function getAndRunTransaction(spannerConn, trxFunc) {
return Promise.using(getTransaction(spannerConn), ([dbTrx]) => trxFunc(dbTrx));
}
// Elsewhere...
const spanner = new Spanner();
const doStuff = (dbTxn) => {
dbTxn.insert(...);
/* ... */
dbTxn.commit();
};
getAndRunTransaction(spanner, doStuff) // txn cleanup handled for you via disposer
.then((result) => {
// do success stuff
}).catch((err) => {
// Do failure things
}); Could fairly easily be combined with the above approaches to retry on transient errors as well. Disposer docs: http://bluebirdjs.com/docs/api/disposer.html |
@walshie4 you should call |
@callmehiphop The documentation says |
@majelbstoat generally that is true. All |
@callmehiphop what's the takeaway from this issue? Do we need to make something better / something new-- in either case, can you please state the specifics of what that would be? :) |
The takeaway is that our users want a Promise friendly version of |
A bit confused by this. What do you mean by Would that include the case of the transaction being retried automatically? Related to that, if |
If for some reason
We don't throw an error until after we've retried internally.
Assuming the callback for rollback/commit was called, we've already stopped retrying the transaction function. These two functions callbacks shouldn't get called until we've either 1. succeeded or 2. hit our retry limits. As far as retrying a transaction goes, we do not fetch a new session, but instead begin a new transaction on the same session that was used prior. |
Made a gist here [0] with a sample runTransaction setup to help explain this in the future, would really appreciate it if @callmehiphop @stephenplusplus or someone else could chime in if this is correct. The main thing I'm unsure of is if I need to call On that note regarding your answer above:
Can you elaborate here? How could one rollback a failed commit? If it failed isn't there nothing to rollback? Or are you referring to the locally queued operations? [0] https://gist.github.com/walshie4/d340f7f91cf396326933af5a7b835fa6 |
@walshie4 currently that is correct, there would be nothing to rollback, however my understanding is that soon there will be other asynchronous ways to update/insert data besides commit. |
"soon there will be other asynchronous ways to update/insert data besides commit" that is intriguing 🤔 |
Yeah for sure, @callmehiphop do you have any other details or general examples in the space you could point to for more details about this future functionality? Also less related will either you or @stephenplusplus be at Google Next next week in SF? |
Just to update this, you actually get (further) errors if you're trying to rollback after an error on commit():
It does make it a bit tricky to write a single general-purpose wrapper for a transaction where you don't know if commit was called or not (if the error happened earlier in the transaction, like a not found error or something). Would be nice if rollback() after commit() was just a no-op. |
@walshie4 I've probably already said too much! And I wish, but I am unable to attend. 😦 @majelbstoat I'm not sure if the client library is the right place to make a change like that, but we could add a |
@callmehiphop yeah, that would be great :) |
@crwilcox this one should be safe to close now, yeah? |
For single operations, using promises is straightforward:
However, the story becomes a lot more complicated with
Database.runTransaction
. It takes a callback, and doesn't return a promise.I have a use-case that involves reading from the database, making a request to a remote service, then conditionally writing a row. Finally, I need to do some post-processing on the result. A read-write transaction is important for data integrity. However, the remote request is made with a promise.
Programming this is really quite complicated. In the happy case, I need to put all the post-processing code inside the transaction callback because this:
clearly doesn't work because the transaction is run asynchronously and
user
isn't set by the timeconvertUser
is executed. However, it seems wrong to put object marshalling code inside the transaction when I'm done with all the data needs earlier than that. Error handling for the bad case is also a pain, because any failures in the query or remote request will trigger a promise-rejection, and the handling of that looks pretty confusing code-wise when you're inside a callback.Ideally, I'd love to be able to do something like the simple case:
where runTransaction takes care of running the callback, returning the result of the commit, or rejecting a promise with an error that happens inside the callback.
(As a side-node, the callback would then no longer need to take an error as its first parameter, because runTransaction could just reject with it before the callback is even started. As it stands, I have to handle the possibility of that error on the first line of every transaction.)
Environment details
@google-cloud/spanner
version: 1.5.0The text was updated successfully, but these errors were encountered: