X

Transactions imbriquées dans jOOQ – Java, SQL et jOOQ.


Depuis jOOQ 3.4, nous avons une API qui simplifie la logique transactionnelle au-dessus de JDBC dans jOOQ, et à partir de jOOQ 3.17 et #13502, une API équivalente sera également disponible au-dessus de R2DBC, pour les applications réactives.

Comme pour tout jOOQ, les transactions sont implémentées à l’aide d’une logique explicite basée sur l’API. La logique implicite implémentée dans Jakarta EE et Spring fonctionne très bien pour ces plates-formes, qui utilisent des annotations et des aspects partout, mais le paradigme basé sur les annotations ne correspond pas bien à jOOQ.

Cet article montre comment jOOQ a conçu l’API de transaction et pourquoi le Spring Propagation.NESTED la sémantique est la valeur par défaut dans jOOQ.

Suivre les valeurs par défaut de JDBC

Dans JDBC (autant que dans R2DBC), une instruction autonome est toujours non transactionnelle ou auto-validante. Il en va de même pour jOOQ. Si vous transmettez une connexion JDBC non transactionnelle à jOOQ, une requête comme celle-ci sera également validée automatiquement :

ctx.insertInto(BOOK)
   .columns(BOOK.ID, BOOK.TITLE)
   .values(1, "Beginning jOOQ")
   .values(2, "jOOQ Masterclass")
   .execute();

Jusqu’ici tout va bien, cela a été une valeur par défaut raisonnable dans la plupart des API. Mais généralement, vous ne vous engagez pas automatiquement. Vous écrivez la logique transactionnelle.

Lambda transactionnels

Si vous souhaitez exécuter plusieurs instructions dans une seule transaction, vous pouvez écrire ceci dans jOOQ :

// The transaction() call wraps a transaction
ctx.transaction(trx -> {

    // The whole lambda expression is the transaction's content
    trx.dsl()
       .insertInto(AUTHOR)
       .columns(AUTHOR.ID, AUTHOR.FIRST_NAME, AUTHOR.LAST_NAME)
       .values(1, "Tayo", "Koleoso")
       .values(2, "Anghel", "Leonard")
       .execute();

    trx.dsl()
       .insertInto(BOOK)
       .columns(BOOK.ID, BOOK.AUTHOR_ID, BOOK.TITLE)
       .values(1, 1, "Beginning jOOQ")
       .values(2, 2, "jOOQ Masterclass")
       .execute();

    // If the lambda is completed normally, we commit
    // If there's an exception, we rollback
});

Le modèle mental est exactement le même qu’avec Jakarta EE et Spring @Transactional aspects. L’achèvement normal valide implicitement, l’achèvement exceptionnel annule implicitement. Le lambda entier est une “unité de travail” atomique, ce qui est assez intuitif.

Vous êtes propriétaire de votre flux de contrôle

S’il y a une exception récupérable dans votre code, vous êtes autorisé à le gérer avec élégance, et la gestion des transactions de jOOQ ne le remarquera pas. Par exemple:

ctx.transaction(trx -> {
    try {
        trx.dsl()
           .insertInto(AUTHOR)
           .columns(AUTHOR.ID, AUTHOR.FIRST_NAME, AUTHOR.LAST_NAME)
           .values(1, "Tayo", "Koleoso")
           .values(2, "Anghel", "Leonard")
           .execute();
    }
    catch (DataAccessException e) {

        // Re-throw all non-constraint violation exceptions
        if (e.sqlStateClass() != C23_INTEGRITY_CONSTRAINT_VIOLATION)
            throw e;

        // Ignore if we already have the authors
    }

    // If we had a constraint violation above, we can continue our
    // work here. The transaction isn't rolled back
    trx.dsl()
       .insertInto(BOOK)
       .columns(BOOK.ID, BOOK.AUTHOR_ID, BOOK.TITLE)
       .values(1, 1, "Beginning jOOQ")
       .values(2, 2, "jOOQ Masterclass")
       .execute();
});

Il en va de même pour la plupart des autres API, y compris Spring. Si Spring n’est pas au courant de vos exceptions, il n’interprétera pas ces exceptions pour la logique transactionnelle, ce qui est parfaitement logique. Après tout, n’importe quelle bibliothèque tierce peut lancer et intercepter des exceptions internes sans toi remarquant, alors pourquoi Spring devrait-il le remarquer.

Propagation des transactions

Jakarta EE et Spring offrent une variété de modes de propagation des transactions (TxType à Jakarta EE, Propagation au printemps). La valeur par défaut dans les deux est REQUIRED. J’ai essayé de rechercher pourquoi REQUIRED est la valeur par défaut, et non NESTED, ce que je trouve beaucoup plus logique et correct, comme je l’expliquerai plus tard. Si vous le savez, faites-le moi savoir sur Twitter ou dans les commentaires :

Mon hypothèse pour ces API est

  1. NESTED a besoin SAVEPOINT prise en charge, qui n’est pas disponible dans tous les RDBMS prenant en charge les transactions
  2. REQUIRED évite SAVEPOINT surcharge, ce qui peut être un problème si vous n’avez pas réellement besoin d’imbriquer les transactions (bien que nous puissions affirmer que l’API est alors annotée à tort avec trop d’accessoires @Transactional annotations. Tout comme vous ne devriez pas courir sans réfléchir SELECT *vous ne devriez pas tout annoter sans y réfléchir suffisamment.)
  3. Il n’est pas improbable que dans le code utilisateur Spring, chaque la méthode de service est simplement annotée aveuglément avec @Transactional sans trop réfléchir à ce sujet (comme pour la gestion des erreurs), puis en effectuant des transactions REQUIRED au lieu de NESTED serait juste une valeur par défaut plus pratique “pour le faire fonctionner”. Ce serait en faveur de REQUIRED étant plus un défaut accidentel qu’un défaut bien choisi.
  4. JPA ne peut pas vraiment bien fonctionner avec NESTED transactions, car les entités deviennent corrompues (voir le commentaire de Vlad à ce sujet). À mon avis, c’est juste un bogue ou une fonctionnalité manquante, bien que je puisse voir que l’implémentation de la fonctionnalité est très complexe et n’en vaut peut-être pas la peine dans JPA.

Ainsi, pour toutes ces raisons purement techniques, il semble compréhensible que des API comme Jakarta EE ou Spring ne fassent pas NESTED la valeur par défaut (Jakarta EE ne le supporte même pas du tout).

Mais c’est jOOQ et jOOQ a toujours pris du recul pour réfléchir à la façon dont les choses devrait êtreplutôt que d’être impressionné par la façon dont les choses sont.

Quand tu penses au code suivant :

@Transactional
void tx() {
    tx1();

    try {
        tx2();
    }
    catch (Exception e) {
        log.info(e);
    }

    continueWorkOnTx1();
}

@Transactional
void tx1() { ... }

@Transactional
void tx2() { ... }

L’intention du programmeur qui a écrit ce code ne peut être qu’une chose :

  • Démarrez une transaction globale dans tx()
  • Effectuez un travail transactionnel imbriqué dans tx1()
  • Essayez d’effectuer d’autres tâches transactionnelles imbriquées dans tx2()
    • Si tx2() réussit, très bien, passez à autre chose
    • Si tx2() échoue, enregistrez simplement l’erreur, ROLLBACK à avant tx2()et avance
  • Indépendamment de tx2()continuez à travailler avec tx1()‘s (et peut-être aussi tx2()‘s) résultat

Mais ce n’est pas ce REQUIRED, qui est la valeur par défaut dans Jakarta EE et Spring, fera l’affaire. Il ne fera que revenir en arrière tx2() et tx1()laissant la transaction externe dans un état très étrange, ce qui signifie que continueWorkOnTx1() échouera. Mais doit-il vraiment échouer ? tx2() était censé être une unité atomique de travail, indépendamment de qui l’appelait. Ce n’est pas le cas par défaut, donc le Exception e devoir être propagé. La seule chose que l’on puisse faire dans catch bloquer, avant de relancer obligatoirement, est de nettoyer certaines ressources ou de faire de la journalisation. (Bonne chance pour vous assurer que chaque développeur respecte ces règles !)

Et, une fois qu’on relance obligatoirement, REQUIRED devient effectivement le même que NESTED, sauf qu’il n’y a plus de points de sauvegarde. Ainsi, la valeur par défaut est :

  • Le même que NESTED dans le chemin heureux
  • Bizarre dans le chemin pas si heureux

Ce qui est un argument de poids en faveur de faire NESTED la valeur par défaut, au moins dans jOOQ. Maintenant le discussion twitter liée digressé un peu dans les préoccupations architecturales de pourquoi:

  • NESTED est une mauvaise idée ou ne fonctionne pas partout
  • Le verrouillage pessimiste est une mauvaise idée
  • etc.

Je ne suis pas en désaccord avec bon nombre de ces arguments. Pourtant, en se concentrant seul sur le code listé, et en me mettant dans la peau d’un développeur de bibliothèque, qu’est-ce que le programmeur aurait pu vouloir par ce code ? Je ne vois rien d’autre que le printemps NESTED sémantique des transactions. Je ne peux tout simplement pas.

jOOQ implémente la sémantique NESTED

Pour les raisons ci-dessus, les transactions de jOOQ n’implémentent que Spring’s NESTED sémantique si les points de sauvegarde sont pris en charge, ou échouent complètement à l’imbrication s’ils ne sont pas pris en charge (étrangement, ce n’est pas une option dans Jakarta EE et Spring, car ce serait une autre valeur par défaut raisonnable). La différence avec Spring étant, encore une fois, que tout est fait par programme et explicitement, plutôt que d’utiliser implicitement des aspects.

Par exemple:

ctx.transaction(trx -> {
    trx.dsl().transaction(trx1 -> {
        // ..
    });

    try {
        trx.dsl().transaction(trx2 -> {
            // ..
        });
    }
    catch (Exception e) {
        log.info(e);
    }

    continueWorkOnTrx1(trx);
});

Si trx2 échoue avec une exception, seulement trx2 est annulé. Pas trx1. Bien sûr, vous pouvez toujours relancer l’exception pour tout annuler. Mais la position ici est que si vous, le programmeur, dites à jOOQ d’exécuter une transaction imbriquée, eh bien, jOOQ obéira, car c’est ce que vous voulez.

Vous ne pourriez pas vouloir autre chose, car alors, vous n’imbriquerez pas la transaction en premier lieu, non ?

Opérations R2DBC

Comme mentionné précédemment, jOOQ 3.17 prendra (enfin) en charge les transactions également dans R2DBC. La sémantique est exactement la même qu’avec les API de blocage de JDBC, sauf que tout est maintenant un Publisher. Ainsi, vous pouvez maintenant écrire :

Flux<?> flux = Flux.from(ctx.transactionPublisher(trx -> Flux
    .from(trx.dsl()
        .insertInto(AUTHOR)
        .columns(AUTHOR.ID, AUTHOR.FIRST_NAME, AUTHOR.LAST_NAME)
        .values(1, "Tayo", "Koleoso")
        .values(2, "Anghel", "Leonard"))
    .thenMany(trx.dsl()
        .insertInto(BOOK)
        .columns(BOOK.ID, BOOK.AUTHOR_ID, BOOK.TITLE)
        .values(1, 1, "Beginning jOOQ")
        .values(2, 2, "jOOQ Masterclass"))
}));

L’exemple utilise le réacteur comme implémentation d’API de flux réactifs, mais vous pouvez également utiliser RxJava, Mutiny ou autre. L’exemple fonctionne exactement de la même manière que celui de JDBC, initialement.

L’imbrication fonctionne également de la même manière, de la manière habituelle, réactive (c’est-à-dire plus laborieuse):

Flux<?> flux = Flux.from(ctx.transactionPublisher(trx -> Flux
    .from(trx.dsl().transactionPublisher(trx1 -> { ... }))
    .thenMany(Flux
        .from(trx.dsl().transactionPublisher(trx2 -> { ... }))
        .onErrorContinue((e, t) -> log.info(e)))
    .thenMany(continueWorkOnTrx1(trx))
));

Le séquençage utilisant thenMany() n’est qu’un exemple. Vous pouvez avoir besoin de primitives de création de flux entièrement différentes, qui ne sont pas strictement liées à la gestion des transactions.

Conclusion

L’imbrication des transactions est parfois utile. Avec jOOQ, la propagation des transactions est beaucoup moins un sujet qu’avec Jakarta EE ou Spring car tout ce que vous faites est généralement explicite, et en tant que tel, vous n’imbriquez pas accidentellement des transactions, quand vous le faites, vous le faites intentionnellement. C’est pourquoi jOOQ a opté pour une valeur par défaut différente de celle de Spring, et celle que Jakarta EE ne prend pas du tout en charge. Le Propagation.NESTED la sémantique, qui est un moyen puissant de garder la logique laborieuse liée aux points de sauvegarde hors de votre code.