-
Notifications
You must be signed in to change notification settings - Fork 280
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
More fine-grained transaction API with savepoints? #257
Comments
Great idea! 💡
|
Excellent points! I didn't originally add the So here's the revised API: try {
const trx = await db.startTransaction().setIsolationLevel('serializable').execute()
await trx.insertInto(...).execute()
await trx.savepoint('foobar').execute()
try {
await trx.updateTable(...).execute()
await trx.updateTable(...).execute()
await trx.releaseSavepoint('foobar').execute()
} catch (err) {
await trx.rollbackToSavepoint('foobar').execute()
}
await trx.commit().execute()
} catch (err) {
await trx.rollback().execute()
}
We could still provide the old API for convenience. Most of the time people would probably still want to use that one. |
Another good thing about this, is that it allows compiling transactions, e.g. for display in the browser. |
Actually that's not the case 🤔 We have this API in the driver: /**
* Begins a transaction.
*/
beginTransaction(
connection: DatabaseConnection,
settings: TransactionSettings
): Promise<void>
/**
* Commits a transaction.
*/
commitTransaction(connection: DatabaseConnection): Promise<void>
/**
* Rolls back a transaction.
*/
rollbackTransaction(connection: DatabaseConnection): Promise<void> Dialects have the ability to start transactions however they want. For example the kysely-data-api does a "start transaction" HTTP request instead of running a We can't allow those to be compiled since some dialects in fact don't compile anything. |
I see, yep that wouldn't work for such dialects.. Can't we make the new API fully compilable, not requesting directly from these methods in the driver? So dialects that actually perform a sql query to begin a transaction can enjoy this.. |
I don't see how? What would |
Not sure, maybe dummy response such as |
Could we make these driver methods optional?
If this makes sense, a rename that describes this "non-sql begin transaction method" situation would make sense. |
Optional driver methods could make sense. But on the other hand, jumping through too many hoops to allow the user to compile things that will always be the string Also: MySQL's if (settings.isolationLevel) {
// On MySQL this sets the isolation level of the next transaction.
await connection.executeQuery(
CompiledQuery.raw(
`set transaction isolation level ${settings.isolationLevel}`
)
)
}
await connection.executeQuery(CompiledQuery.raw('begin')) unlike PostgreSQL, MySQL doesn't allow isolation level to be set in the same query as const cn = await db.getConnection();
await cn.setTransactionIsolationLevel('serializable').execute()
const ctx = await cn.startTransction().execute()
...
await cn.release(); which is a little bit too detailed? |
Yeah this is not really worth it, for just 1-2 sql statements that are very predictable. Our consumers could simply prepend them manually if they want to display them. 👍 |
JS is set to get explicit resource management: There is also an async version in planning: I think transactions could really benefit from this. They are also listed as a use case (example taken from the proposal, our implementation may be different): async function transfer(account1, account2) {
using await tx = transactionManager.startTransaction(account1, account2);
await account1.debit(amount);
await account2.credit(amount);
// mark transaction success if we reach this point
tx.succeeded = true;
} // await transaction commit or rollback Maybe this is something what would be interesting for the design of a transaction API in the future. Since the sync version of the proposal was accepted, chances are that we'll also get an async version. :) |
This feature is great for javascript, but redundant in Kysely, since the current transaction API already abstracts away commit & rollback so consumers can focus on content. This issue's purpose is to provide consumers with a more fine-grained control over transactions, to live alongside the current API (not replace it). The demographic for this feature wants to call
This is such a footgun. Hate it. |
This would also be handy for parallelizing automated tests (each in their own transaction) that talk to the database without having them commit the data. Currently, I truncate tables so the tests have to run single order. With finer grained controls over rollbacking back I can rollback after each test. |
Is there any update regarding this? kyselyClient.transaction()
.execute(async (trx) => {
queries.map((query) => trx.execute(query))
}); However, we would prefer to be able to use the connection for the transaction to perform each query as it comes in. Some pseudocode would be: class TransactionBatcher {
addQuery(query: Query) {
if (!this.transactionHasBegun) {
this.kyselyClient.beginTransaction();
this.transactionHasBegun = true;
}
try {
this.kyselyClient.execute(query);
} catch (e) {
logger(`Found an error in query: ${query}`);
}
}
commitQueries() {
try {
this.kyselyClient.commitTransaction();
} catch (e) {
logger('Could not commit query');
this.kyselyClient.rollbackTransaction();
}
}
} |
@rdgout We'll update this issue when we have updates. You can achieve the same thing using the current API with a small helper function: async function begin(db: Kysely<DB>) {
const connection = new Deferred<Transaction<DB>>()
const result = new Deferred<any>()
// Do NOT await this line.
db.transaction().execute(trx => {
connection.resolve(trx)
return result.promise;
}).catch(err => {
// Don't do anything here. Just swallow the exception.
})
const trx = await connection.promise;
return {
trx,
commit() {
result.resolve(null)
},
rollback() {
result.reject(new Error('rollback'))
}
}
}
export class Deferred<T> {
readonly #promise: Promise<T>
#resolve?: (value: T | PromiseLike<T>) => void
#reject?: (reason?: any) => void
constructor() {
this.#promise = new Promise<T>((resolve, reject) => {
this.#reject = reject
this.#resolve = resolve
})
}
get promise(): Promise<T> {
return this.#promise
}
resolve = (value: T | PromiseLike<T>): void => {
if (this.#resolve) {
this.#resolve(value)
}
}
reject = (reason?: any): void => {
if (this.#reject) {
this.#reject(reason)
}
}
} |
@koskimas Is there a way to achieve individual test cases running in transactions? This would have to support nested transactions in case the business logic that's being tested has transactions itself, probably in the form of savepoints. |
crude but works in sqlite export function createSavePoint(db: Kysely<any> | Transaction<any>, name?: string) {
const _name = name || `sp_${new Date().getTime()}`
return {
save: async () => {
await sql`savepoint ${sql.raw(_name)}`.execute(db)
},
release: async () => {
await sql`release savepoint ${sql.raw(_name)}`.execute(db)
},
rollback: async () => {
await sql`rollback to savepoint ${sql.raw(_name)}`.execute(db)
},
}
} await db.transaction().execute(async (trx) => {
const s1 = createSavePoint(trx)
await s1.save()
await trx.insertInto('test').values({ person: `test ${Date.now()}` }).execute()
const s2 = createSavePoint(trx)
try {
await s2.save()
await trx.insertInto('test').values({ person: `test ${Date.now()}` }).execute()
await s2.rollback()
// throw new Error('test error')
s1.release()
} catch (error) {
s1.rollback()
}
}) |
I think this is a very important perspective. When testing functions that use transactions by utilizing transaction rollback, it is ideal to support nested transactions so as not to interfere with the internal transactions of the function. Therefore, supporting nested transactions, such as wrapping savepoint api, seems ideal to me. function doSomethingUsingTransaction() {
db.transaction().execute(tx => {
...
})
}
// test code
export const testTrx = async <T>(fn: (tx: Transaction<DB>) => Promise<T>) => {
let result: T;
try {
await db.transaction().execute(async (tx) => {
result = await fn(tx);
throw new TestTrxRollback();
});
} catch (e) {
if (!(e instanceof TestTrxRollback)) throw e;
}
return result!;
};
describe('check something', () => {
it('should rollback', () => {
testTrx(async (tx) => {
doSomethingUsingTransaction(tx);
});
});
}); |
@igalklebanov @koskimas |
Curious: are nested transactions going to be possible with that pr? Specifically for my use case I have been testing a server that has itself transactions, but passing in a transaction into my createServer function to act as the parent db connection doesn't work since nested transactions are not allowed. I get |
Yes, in the form of savepoints. |
@igalklebanov Do you have any answers or initiatives regarding this problem awareness? |
I see #962 is merged. Can we please have a new release? |
Can the new API be extended to support vendor extensions like sqlite3's |
Different issue. Probably. |
I'm looking forward to a new release now that #962 is merged. |
The text was updated successfully, but these errors were encountered: