vertx-sql-client icon indicating copy to clipboard operation
vertx-sql-client copied to clipboard

Propagate transactions in context

Open technical-debt-collector opened this issue 3 years ago • 34 comments

Read me

Read this first before creating an issue:

  • do not use this issue tracker to ask questions, instead use one of these channels. Questions will likely be closed without notice.
  • you shall create a feature request only when it is general purpose enough.
  • make sure that the feature is not already

Describe the feature

While the simplified transaction API works great with singular operations or statements that can be grouped together, there doesn't seem to be any practical way of encapsulating multiple separately implemented DB statements within a transaction, such as in the case of controller layers.

See short discussion in https://github.com/quarkusio/quarkus/issues/26705 where Gavin suggested that propagating the transaction via the Vert.x context is the way to address this.

Use cases

  • Transaction handling that is easier to manage

Contribution

@vietj @tsegismont

I'd be happy to try looking further into this myself if you think this could be worth exploring, but I am fairly unexperienced with the project so it'd probably require some time and assistance.

can you describe more precisely what you would expect ? i.e give a code example of how you think it would / should work. It would help a lot.

vietj avatar Jul 15 '22 09:07 vietj

I am currently using Mutiny and not Vert.x directly so I might be a little out of touch here. But could something like the following work?

  1. Create a new method on interface Pool that:
  • Looks for an active connection via the local context and returns it if found
  • Otherwise create another, place that in the local context and return it
  1. Add the transaction accessor to Connection as requested in #646

Then third party code could easily retrieve and keep using both the active connection and the associated transaction without having to hold onto either of them.

Alternatively, instead of creating a new method, the existing getConnection-methods functionality could just place the connection they acquire in local context while also returning it, but I'm not sure if that could have unfortunate side effects?

@vietj you could look at how HR does it for inspiration: essentially, we store the connection in the vert.x local context from the outermost call to withTransaction(), and pull it from there in nested calls.

gavinking avatar Jul 21 '22 02:07 gavinking

@technical-debt-collector so something like getOrCreateContext() but with a connection

vietj avatar Jul 21 '22 06:07 vietj

@vietj Yes, essentially.

@technical-debt-collector so something like getOrCreateContext() but with a connection

But I would make it accept a function reference.

gavinking avatar Jul 21 '22 10:07 gavinking

@gavinking, do you mean like with Pool.withTransaction() where the transaction is committed/rolled back and the connection is released after the function execution? I think I'd personally prefer having a variant without a function reference as well, where I can manage this myself.

do you mean like with Pool.withTransaction() where the transaction is committed/rolled back and the connection is released after the function execution?

Right. This was we can guarantee that things really do get cleaned up and rolled back.

I think I'd personally prefer having a variant without a function reference as well, where I can manage this myself.

If you really want to write custom code and manage things yourself, then that's already possible. I see this more as a feature for people who don't want to think about exception handling, etc.

gavinking avatar Jul 21 '22 15:07 gavinking

I guess either way (functional reference or manual management) is already possible. What I'd expect to get out from this would be a way to handle both connections and transactions without having to pass them around and incorporate them across various layers of code just to achieve the necessary transaction management, ideally without having to touch the Vert.x context which isn't really conveniently available in Mutiny anyways.

With that being said I've thought more about it and fully agree with you, @gavinking. It's probably better to implement it in a safer way on behalf of the users as I don't think the manual way provides any significant advantages over the functional reference way.

But in that case, the SQL client is left with two almost identical ways of encapsulating logic within a transaction, one of which requires passing the connection object around. Should the old one be deprecated in this case? The only case I can imagine where the old one is still useful is if you for some reason would like to leave specific operations outside of the current transaction, but I guess this can be done in better ways if you'd actually want something done in separate transactions. And, does this SQL client even support multiple active transactions at a time?

@vietj, what are your thoughts on this? If you agree then I'd be happy to provide a PR for it.

I think that withTransaction already exist and we could overload the method with a boolean bind argument that would locally bind the transaction to the current vertx context (and fail if there is already a transaction) for the duration of the transaction and we would only provide a getter for this statically bound transaction ? i.e something like

pool.withTransaction(true, tx -> {
  assertSame(tx, pool.currentTransaction());
  return someFuture(tx);
});

vietj avatar Jul 22 '22 12:07 vietj

I mean, that works, but I sorta hate Boolean parameters. In this case my preference would be to have a new method.

gavinking avatar Jul 26 '22 04:07 gavinking

well that could be a TransactionOptions object too

vietj avatar Jul 26 '22 07:07 vietj

Not sure if I understood the solution described above.

In any case I experimented with the earlier solutions we discussed but it doesn't look like vertx-codegen will accept any other functional interface type than Function as a parameter to such a method. Using Void e.g. pool.withTransaction((Function<Void, Future<@Nullable T>> function) would work though.

I think that the case description is not precise enough @technical-debt-collector

vietj avatar Jul 27 '22 06:07 vietj

Today, users who wish to do operations transactionally can choose between:

  1. pool.withTransaction() which provides a Connection object from which statements can be executed within a transaction. And at the end, that transaction automatically commits/rolls back along with the connection being closed
  2. pool.getConnection which gets the connection, and via that, start a transaction. In this case users must manually hold onto both of these objects if they are to handle everything that 1. does automatically.

The idea is to introduce an alternative to pool.withTransaction() where the connection is stored in the context instead of having to be passed around as a parameter through the Function argument. That way anything running in the same Vert.x process can access the same connection object via the pool instance and there would be no need to pass the object around as a parameter.

E.g. (in Mutiny)

public Uni<Void> transactionalMethod() {
        return pool.withTransaction(() -> {
            return Uni.combine().all().unis(
                    insertSomething(),
                    updateSomethingElse(),
                    evenMoreWork()
            );
        });
    }

Somewhere else in my code I could have the other methods implemented, and I wouldn't need to supply the connection parameter to them at all, they could just look it up via the pool instance themselves and they would then be a part of the transaction started in transactionalMethod().

public Uni<Void> insertSomething() {
        return pool
                .getCurrentConnection()
                .map(connection -> connection
                        .preparedQuery("insert into example (value) values ($1)")
                        .execute(Tuple.of("something")))
                .flatMap(r -> Uni.createFrom().voidItem());
    }

I see now, I think it requires specific code to duplicate a context for the duration of the transaction because the current context might not be a duplicate context and this it would not work as expected.

And perhaps that there could also be concurrent transaction within the same HTTP request and the duplicate context would not be able to store two transactions.

vietj avatar Jul 28 '22 07:07 vietj

I think an interface like:

public interface TransactionLocal {

  static TransactionLocal create(Pool pool) {
    ...
  }

  SqlConnection currentConnection();

  <T> Future<@Nullable T> withTransaction(Function<SqlConnection, Future<@Nullable T>> fn);

}

would work, since you can create a static TransactionLocal object that is shared, and that does not prevent one to have multiple instances if that's a requirement.

vietj avatar Jul 28 '22 07:07 vietj

Sounds pretty solid! Want me to give this PR an attempt? Edit: Actually, the interface should be for the SqlConnection and not the Transaction object, right? Because that's the object being passed around (which we'd like to stop doing) and used to actually encompass things within a transaction - not the actual transaction object itself. But in that case, would this even work? If the context is not an appropriate place to store it, I don't really see where we could hold onto a SqlConnectionLocal-instance.

One connection seems to only support one transaction at a time from the looks of it, but that seems fine to me at least for this issue.

Sure thing, contributions are welcome.

On Thu, Jul 28, 2022 at 11:55 AM Ola Sæter Heitmann < @.***> wrote:

Sounds pretty solid! Want me to give this PR an attempt?

— Reply to this email directly, view it on GitHub https://github.com/eclipse-vertx/vertx-sql-client/issues/1214#issuecomment-1197925093, or unsubscribe https://github.com/notifications/unsubscribe-auth/AABXDCRT3Y7FBRVFCAZOUXLVWJKIDANCNFSM53RIKKFQ . You are receiving this because you were mentioned.Message ID: @.***>

vietj avatar Jul 29 '22 07:07 vietj

The API described above is not really what I was thinking of, and it doesn't seem to me to be as easy to use as what we have in Hibernate Reactive. In HR, propagation of the connection and transaction happens completely transparently between nested calls to withTransaction().

gavinking avatar Jul 29 '22 08:07 gavinking

So instead of calling getCurrentConnection() you just call withTransaction() again, and get passed everything you need (here it would be the connection and transaction objects created by the surrounding call to withTransaction()).

gavinking avatar Jul 29 '22 08:07 gavinking

locally bind the transaction to the current vertx context (and fail if there is already a transaction)

To be clear, I think this is where our ideas diverge. You're saying fail; I'm saying just return it.

gavinking avatar Jul 29 '22 08:07 gavinking

So instead of calling getCurrentConnection() you just call withTransaction() again, and get passed everything you need (here it would be the connection and transaction objects created by the surrounding call to withTransaction()).

Having to wrap every request in its own withTransaction wherever it's impractical to just include it in another seems kind of annoying, though. Wouldn't it be better having to only deal with it once if possible?

Well on the contrary I see this as much simpler.

gavinking avatar Jul 29 '22 09:07 gavinking

can you provide pseudo code examples to illustrate ?

vietj avatar Jul 29 '22 09:07 vietj

Well, I mean, the previous example would look something approximately like:

   public Uni<Void> transactionalMethod() {
        return pool.withTransaction((tx, c) -> {
            return Uni.combine().all().unis(
                    insertSomething(),
                    updateSomethingElse(),
                    evenMoreWork()
            );
        });
    }

   public Uni<Void> insertSomething() {
        return pool.withTransaction((tx, c) ->
                c.preparedQuery("insert into example (value) values ($1)")
                        .execute(Tuple.of("something"))))
            .flatMap(r -> Uni.createFrom().voidItem());
    }

Note that I didn't have to write the insertSomething() method to be aware of the fact that it was being called from within the scope of an existing transaction. The outer transaction just automatically propagated.

gavinking avatar Aug 04 '22 11:08 gavinking

@gavinking it reminds me EJB required transaction, is that right ?

vietj avatar Aug 08 '22 06:08 vietj

Yes, precisely.

gavinking avatar Aug 08 '22 06:08 gavinking

ok, then it's not the same thing I was thinking of initially

vietj avatar Aug 09 '22 04:08 vietj

It might make sense that withTransaction works with this way as nested transaction don't exist. Perhaps we should make this configurable to have nesting reuse the current transaction or fail (it might be an error if that's used unwillingly).

vietj avatar Aug 11 '22 09:08 vietj