Hibernateのトランザクションのトラブル

【概要】
Hibernateトランザクション周りで遭遇した現象を書いてみます。
前提と現象のところに書いてあるのですが、現象の問題領域が
JUnit単体テストにとどまったため、バージョンを変えた場合の
検証やもっと奥深く突っ込んだ検証は(業務的に)打ち切りとなりました。
なので、まとまった情報という感じではありませんが、もし他に
似たような現象に遭遇した人がいたら、その人のための情報共有として。
【環境】
s2framework-2.4.38
s2extension-2.4.38
s2tiger-2.4.38
s2hibernate-jpa-1.0.1.jar
hibernate-3.2.7.ga.jar
hibernate-annotations-3.2.1.ga.jar
hibernate-entitymanager-3.2.1.ga.jar
【前提】
Hibernateの最新バージョンを使うとどうなるかは試してない。

Seasarのバージョンに関しては、
http://s2hibernate.seasar.org/ja/s2hibernate-jpa.html
を見ると「2.4.19」で動作確認がされているとのことなので、
利用しているバージョンは逆に新しすぎるのだが、バージョンを
戻しての検証はしていない。

また、個人的にはHibernateは詳しくないので、そもそも利用の
仕方に不備があるかもしれない。
【現象】
呼び出し側で既にトランザクションを開始していて、トランザクションが
開始されなかった「@RequiredTx」のメソッドの中で例外が発生した場合に、
トランザクションが継続している呼び出し側の領域で例外をcatchして、
再度DBアクセスして(別の@RequiredTxのメソッドを呼ぶ)、
その後flush()を呼び出すと例外が発生してしまう。
「javax.persistence.TransactionRequiredException: no transaction is in progress」
    at ...AbstractEntityManagerImpl.flush(AbstractEntityManagerImpl.java:293)
    at ...TxScopedEntityManagerProxy.flush(TxScopedEntityManagerProxy.java:246)
トランザクションは継続されているのに「トランザクションが必要」と言われる。

例えば、以下のようなメソッド呼び出し構造で、
foo()で例外発生してtop()でcatchしてbar()を呼び出すと、
最後のflush()でTransactionRequiredExceptionが発生する。
@RequiresNewTx
public void top() {
    try {
        foo();
    } catch (RuntimeException e) {
        bar();
        throw e;
    } finally {
        flush();
    }
}

@RequiredTx
public void foo() {
    throw new RuntimeException();
}

@RequiredTx
public void bar() {
    // ここでDBアクセスすると後のflush()で
    // TransactionRequiredException
    // 
    // 「検索のみ」でも「検索+更新」でも発生する
    // 業務的にはここで更新がしたい
}
ちなみにflushをする理由は、flushをしないと最後のbar()での更新処理が
実行されない現象が発生したため。これは別の問題かもしれない。

呼び出し側でトランザクションを開始せずに@RequiredTxが有効になるように
実行した場合は問題が発生しなかった。呼び出し側でトランザクションを開始
する動機は主にJUnitからの単体テストのためであり、業務的には例外系の
テストが書けない(書きにくい)というだけの問題にとどまった。
(本番実行時は、@RequiredTxがそれぞれ独立したトランザクションになる)
【切り分け】
RequiredInterceptorで指定されているDefaultTransactionCallbackの
applyTxRule()の「adapter.setRollbackOnly();」が実行されないようにすると、
現象は発生しない。
「Hibernateと最新バージョンするとどうなるか?」
「Seasarの(ドキュメントに書いてる)バージョンを戻すとどうなるか?」
は確認できていない。
【考察】
RequiredInterceptorがトランザクションを引き継いだ場合に、
例外発生時にコミットやロールバック処理はしないが、
デフォルトではsetRollbackOnly()はするようになっている。
これはSeasar-MLにて仕様と確認済み。TxRuleの設定で挙動の変更は可能。
今後の知識として注意して覚えておきたいのが:

「@RequiredTxはトランザクションを引き継いだ場合は存在しないのと同じ」

ではなく(自分はそう思ってしまっていた...)、

「@RequiredTxはトランザクションを引き継いだ場合でも例外発生時に
 setRollbackOnly()だけはする(設定で変更可能)」

という認識でいる必要がある。
なぜ「adapter.setRollbackOnly()」が呼び出されると現象が発生するのか?

「adapter.setRollbackOnly()」でJTAトランザクションのStatusが「1」になる。
「1」は「Status.STATUS_MARKED_ROLLBACK」である。
foo()での例外発生前と発生後ではこの点だけが違う。
EntityManagerの状態はOpen状態、JoinStatusもJOINEDと共に
例外発生前と後で何も変わらない
しかし、この違いだけでflush()メソッドの中で行っている
「トランザクションの有効判定」がfalseを戻して例外となる。

AbstractEntityManagerImpl.flush()
 → AbstractEntityManagerImpl.isTransactionInProgress()
  → SessionImpl.isTransactionInProgress()
   → JDBCContext.isTransactionInProgress()
    → JoinableCMTTransactionFactory.isTransactionInProgress()
     → JoinableCMTTransaction.isTransactionInProgress()
      → JTAHelper.isRollback()
【org.hibernate.impl.SessionImpl】
public boolean isTransactionInProgress() {
    checkTransactionSynchStatus();
    return !isClosed() && jdbcContext.isTransactionInProgress();
}

【org.hibernate.ejb.transaction.JoinableCMTTransaction】
private boolean isTransactionInProgress(javax.transaction.Transaction tx) throws SystemException {
    return JTAHelper.isTransactionInProgress(tx) && ! JTAHelper.isRollback( tx.getStatus() );
}

【org.hibernate.util.JTAHelper】
public static boolean isRollback(int status) {
    return status==Status.STATUS_MARKED_ROLLBACK ||
           status==Status.STATUS_ROLLING_BACK ||
           status==Status.STATUS_ROLLEDBACK;
}
JTAHelper.isRollback( tx.getStatus() ) でJTAトランザクションの
ステータスがロールバック状態なのかどうかを判定しているのだが、
その判定の中にStatus.STATUS_MARKED_ROLLBACKが含まれているので、
このメソッドが true を戻し、isTransactionInProgress()がfalseと
なり、flush()時に例外となる。
【回避策】
RequiredInterceptorにTxRuleを設定することで、
例外発生時に「adapter.setRollbackOnly()」が実行されないようにすれば、
想定通りの実行はできた。が、例外発生時に変な状態でコミットされないための
セーフティネットと考えればこの回避は正しいのかどうか不明。

そういうこともあり、かつ、問題領域がテストだけだったので、
業務的には例外系のテストは別の仕組みで行うようにして、
特に拡張は行わなかった。