Skip to content
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

feat: add support for nested transaction rollbacks via savepoints in sql #4375

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

LucianBuzzo
Copy link
Contributor

@LucianBuzzo LucianBuzzo commented Oct 17, 2023

This is my first OSS contribution for a Rust project, so I'm sure I've made some stupid mistakes, but I think it should mostly work :)

This change adds a mutable depth counter, that can track how many levels deep a transaction is, and uses savepoints to implement correct rollback behaviour. Previously, once a nested transaction was complete, it would be saved with COMMIT, meaning that even if the outer transaction was rolled back, the operations in the inner transaction would persist. With this change, if the outer transaction gets rolled back, then all inner transactions will also be rolled back.

Different flavours of SQL servers have different syntax for handling savepoints, so I've had to add new methods to the Queryable trait for getting the commit and rollback statements. These are both parameterized by the current depth.

I've additionally had to modify the begin_statement method to accept a depth parameter, as it will need to conditionally create a savepoint.

Client PR: prisma/prisma#21678

@LucianBuzzo LucianBuzzo requested a review from a team as a code owner October 17, 2023 21:59
@LucianBuzzo LucianBuzzo requested review from miguelff and Weakky and removed request for a team October 17, 2023 21:59
@LucianBuzzo LucianBuzzo force-pushed the lucianbuzzo/sql-nested-transactions branch from 6f375b8 to b984ae4 Compare October 17, 2023 22:00
@CLAassistant
Copy link

CLAassistant commented Oct 17, 2023

CLA assistant check
All committers have signed the CLA.

@LucianBuzzo
Copy link
Contributor Author

This should resolve the issue described here prisma/prisma#15212

@LucianBuzzo
Copy link
Contributor Author

@miguelff @Weakky If you have a moment could you take a look a this? If it works as I think it does ( 🤞 ) it's going to fix a lot of headaches for me and my team! TIA 🙏

@janpio
Copy link
Contributor

janpio commented Oct 27, 2023

Would you consider this a breaking change compared to current behavior @LucianBuzzo? It smells a bit like that to me because current functionality would change. Agree?

@LucianBuzzo
Copy link
Contributor Author

@janpio I think that the current behaviour is unexpected, so this would be a bug fix or new "feature". It's possible that people have made applications that rely on the current behaviour, but this is true of any bug IMO.
As an example of how I consider this a bug: the documentation for transactions shows an example where a transfer between two accounts I rolled back, but it's not explained that this will not work if the transaction is nested. If you try to make behaviour like this and couple it with something like the RLS example from the client extensions, it will fail and the user has to do a lot of debugging to find out why.

@janpio
Copy link
Contributor

janpio commented Oct 28, 2023

Thanks for the explanation @LucianBuzzo, I get it now!

I could also connect it to prisma/prisma#19346 then which is about the same problem.

Did you find a bug report about the incorrect behavior? Optimally this issue would close that one so we have a closed bug in our release notes when we add this, that makes it clearer that this is a "breaking change" only in the context of that it fixes a bug. (Maybe you can create the issue if non exists yet!? Thanks.)

(There are also the related prisma/prisma#9710 and prisma/prisma#12898, but I think they want to completely replace transactions with BEGIN/COMMIT with savepoints - which this PR does not do. Do you agree that there is no overlap?)

This should probably be documented in the future with a new "Nested interactive transactions" sub headline under https://www.prisma.io/docs/concepts/components/prisma-client/transactions#interactive-transactions?

What would be good test cases for prisma/prisma? (Optimally those fail right now, but will succeed when this PR here is merged to show that this properly improves things)

@janpio
Copy link
Contributor

janpio commented Oct 28, 2023

You can ignore all the failing "Driver Adapters" tests, and also the Vitess and MySQL on Linux ones - those are currently flaky.

But these look relevant and need to be fixed:

@janpio janpio self-assigned this Oct 28, 2023
@codspeed-hq

This comment was marked as off-topic.

@LucianBuzzo
Copy link
Contributor Author

LucianBuzzo commented Oct 29, 2023

Thanks for the review @janpio I'll get the code issues resolved.

The issue prisma/prisma#19346 describes the exact problem, I just haven't referenced it as I wanted to have this PR reviewed first. For prisma/prisma#9710 and prisma/prisma#12898 this PR will resolve those issues, as long as they are using an SQL DB.
To get integration tests in an isolated transaction, you would simply need to run your test cases inside an interactive transaction:

describe('some test', () => {
  it('should work', async () => {
    try {
    await prisma.$transaction(async (tx) => {
      //...
      expect(x).toBe(y)
      
      throw new Error('rollback')
    })
    } catch (e) {
      // ignore e
    }
  })
 })

This is a similar pattern to how I discovered this issue myself - trying to test if a user is able to perform an operation when running Yates RBAC.

I'll add some lines about nested transactions to the docs. As for a test case in https://github.com/prisma/prisma a simple way to do it would be to create a client extension that wraps all queries in a transaction and then test that the interactive transaction example in the docs (transferring money between accounts) works as expected.

@janpio
Copy link
Contributor

janpio commented Oct 29, 2023

Wouldn't it then also be possible to just create a standalone test case that wraps multiple transactions, without the need to change how we run tests or use an extension? (I would assume the extension just does that automatically, but it should be possible to also express that explicitly and simpler)

For prisma/prisma#9710 and prisma/prisma#12898 this PR will resolve those issues, as long as they are using an SQL DB.

Don't these suggest to use just savepoints for the actual tests, instead of normal transactions? I am still a bit unclear about the approach.

@LucianBuzzo
Copy link
Contributor Author

From my reading of those issues, it seems that you could achieve the result they want using a nested transaction that utilises savepoints. I would let the original authors correct me if this is not the case though!

@LucianBuzzo
Copy link
Contributor Author

And yes you could not use a client extension in the test case, I simply provided the example above as one possibility 👍

@janpio
Copy link
Contributor

janpio commented Oct 29, 2023

CI run is done, some more Clippy stuff to make it able to compile I guess: https://github.com/prisma/prisma-engines/actions/runs/6682077529/job/18158479734?pr=4375 (I also don't know any Rust, so unfortunately can't be of help here)

@janpio
Copy link
Contributor

janpio commented Oct 29, 2023

(Can you do a fake PR to README.md adding a newline or some other minimal, non-intrusive change that I can merge easily? Then your commits in this PR will automatically run tests going forward - and you don't have to wait for me to click the button😆)

@LucianBuzzo LucianBuzzo force-pushed the lucianbuzzo/sql-nested-transactions branch from da206e7 to a8af640 Compare October 30, 2023 10:16
@LucianBuzzo
Copy link
Contributor Author

@janpio Here's a PR for the Quaint README #4399

@LucianBuzzo
Copy link
Contributor Author

@janpio I've also opened a PR with a failing test case for the prisma client here prisma/prisma#21678

@Jolg42
Copy link
Contributor

Jolg42 commented Nov 1, 2023

Note: I brought the branch into the repo, it will release the engines automatically after a while
https://github.com/prisma/prisma-engines/tree/integration/sql-nested-transactions
https://buildkite.com/prisma/test-prisma-engines/builds/21364

@janpio
Copy link
Contributor

janpio commented Nov 1, 2023

Happened, version shared here: prisma/prisma#21678 (comment)

@LucianBuzzo
Copy link
Contributor Author

@janpio @Jolg42 I think that this commit 5d2cc0a should allow us to send an existing transaction ID to the engine in the case of a nested transaction. I'm not sure how to test this behaviour in this repo - any tips on how to do this, or an existing test I could use as an example?

@LucianBuzzo LucianBuzzo force-pushed the lucianbuzzo/sql-nested-transactions branch from b0bab65 to a220cf5 Compare September 26, 2024 15:31
@aqrln aqrln assigned aqrln and unassigned SevInf Sep 27, 2024
Copy link
Member

@aqrln aqrln left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First round of comments, didn't review the whole PR yet. I'd also like to chat with you about the overall design and what the API on TS side would look like because I don't have the full context here.

quaint/src/connector/mssql/native/mod.rs Outdated Show resolved Hide resolved
quaint/src/connector/mssql/native/mod.rs Outdated Show resolved Hide resolved
quaint/src/connector/mssql/native/mod.rs Outdated Show resolved Hide resolved
quaint/src/connector/mssql/native/mod.rs Outdated Show resolved Hide resolved
quaint/src/connector/mssql/native/mod.rs Outdated Show resolved Hide resolved
quaint/src/connector/queryable.rs Outdated Show resolved Hide resolved
quaint/src/connector/queryable.rs Outdated Show resolved Hide resolved
quaint/src/connector/transaction.rs Show resolved Hide resolved
@@ -36,23 +43,26 @@ pub(crate) struct TransactionOptions {
/// transaction object will panic.
pub struct DefaultTransaction<'a> {
pub inner: &'a dyn Queryable,
pub depth: Arc<Mutex<i32>>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd still prefer an AtomicU32

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So something like this?

Suggested change
pub depth: Arc<Mutex<i32>>,
pub depth: AtomicU32,

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes

Ok(this)
}
}

#[async_trait]
impl<'a> Transaction for DefaultTransaction<'a> {
async fn begin(&mut self) -> crate::Result<()> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do these methods take self by mutable reference? You have interior mutability in depth so this seems unnecessarily restrictive.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might be leftover from the initial version of the PR, before I encapsulated depth inside the Transaction. I'll fix this 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I switch from the Mutex to AtomicU32 I think these will need to be a mutable reference, unless I'm misunderstanding

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, atomics have interior mutability

@LucianBuzzo LucianBuzzo force-pushed the lucianbuzzo/sql-nested-transactions branch from f787095 to b877e71 Compare September 30, 2024 13:22
@luisgrases
Copy link

Still watching this initiative closely and with great interest

@LucianBuzzo
Copy link
Contributor Author

@luisgrases Now that #5001 is merged I think this PR is very close to completion 🚀

@LucianBuzzo LucianBuzzo force-pushed the lucianbuzzo/sql-nested-transactions branch 12 times, most recently from b8ca794 to 670e5ad Compare November 12, 2024 15:36
@LucianBuzzo
Copy link
Contributor Author

Update from my end: all tests are passing except on Mssql, where the default transaction methods are used instead of the Mssql implementation. If anyone has any insight into how to debug this issue please let me know!

This is my first OSS contribution for a Rust project, so I'm sure I've
made some stupid mistakes, but I think it should mostly work :)

This change adds a mutable depth counter, that can track how many levels
deep a transaction is, and uses savepoints to implement correct rollback
behaviour. Previously, once a nested transaction was complete, it would
be saved with `COMMIT`, meaning that even if the outer transaction was
rolled back, the operations in the inner transaction would persist. With
this change, if the outer transaction gets rolled back, then all inner
transactions will also be rolled back.

Different flavours of SQL servers have different syntax for handling
savepoints, so I've had to add new methods to the `Queryable` trait for
getting the commit and rollback statements. These are both parameterized
by the current depth.

I've additionally had to modify the `begin_statement` method to accept a depth
parameter, as it will need to conditionally create a savepoint.

When opening a transaction via the transaction server, you can now pass
the prior transaction ID to re-use the existing transaction,
incrementing the depth.

Signed-off-by: Lucian Buzzo <[email protected]>
@LucianBuzzo LucianBuzzo force-pushed the lucianbuzzo/sql-nested-transactions branch 2 times, most recently from cf219c7 to 8f090ee Compare November 28, 2024 07:00
// MSSQL doesn't have a "RELEASE SAVEPOINT" equivalent, so in a nested
// transaction we just continue onwards
fn release_savepoint_statement(&self, _depth: u32) -> Cow<'static, str> {
Cow::Owned("".to_string())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Cow::Owned("".to_string())
Cow::Borrowed("")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
PR: Feature A PR That introduces a new feature
Projects
None yet
Development

Successfully merging this pull request may close these issues.