CLOVER🍀

That was when it all began.

コンテナ管理トランザクションとEJB Lite?

前にJava EE 6のとっかかりということで、JAX-RSEJBJPAをつなげて遊びましたが、この時はReadが精一杯でした。

で、今回は少し視点を変えてコンテナ管理トランザクションを見ていこうかなと思います。

ちょうどこちらに、ピタリなテーマが。

EJBで複数テーブル保存時の自動ロールバック挙動を確認してみました - Challenge Java EE !

すいません、お借りします。

まあ、そのままではないですが、言語をScalaに、JSFJAX-RSに置き換えて、自分なりに気になるところを確認しようかなと。

テーブル定義は、こんな感じで。

CREATE TABLE opportunity (
  opportunity_id VARCHAR(3),
  opportunity_name VARCHAR(10),
  PRIMARY KEY(opportunity_id)
);

CREATE TABLE quote (
  quote_id VARCHAR(3),
  opportunity_id VARCHAR(3),
  quote_name VARCHAR(10),
  PRIMARY KEY(quote_id)
);

元のブログに直接定義があるわけではないですが、とりあえずこんなところかなと。

永続性ユニットの定義。
src/main/resources/META-INF/persistence.xml

<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="http://java.sun.com/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd"
             version="2.0">
  <persistence-unit name="javaee6.web.pu" transaction-type="JTA">
    <provider>org.hibernate.ejb.HibernatePersistence</provider>
    <jta-data-source>java:jboss/datasources/mysqlXaDs</jta-data-source>
    <properties>
      <property name="hibernate.dialect" value="org.hibernate.dialect.MySQL5InnoDBDialect" />
      <property name="hibernate.show_sql" value="true" />
      <property name="hibernate.format_sql" value="true" />
    </properties>
  </persistence-unit>
</persistence>

CDIも使うので、

src/main/webapp/WEB-INF/beans.xml

も空ファイルで作成します。これ、Java EE 7だと、特に書くことがなければいらなくなるんですか…。まあ、今はEE 6だからいいんですけど、忘れないようにしておきましょう。

エンティティの定義。
src/main/scala/javaee6/web/entity/Entities.scala

package javaee6.web.entity

import scala.beans.BeanProperty

import javax.persistence.{Column, Entity, Id, JoinColumn, OneToOne, Table}

object Opportunity {
  def apply(opportunityId: String, opportunityName: String): Opportunity = {
    val opp = new Opportunity
    opp.opportunityId = opportunityId
    opp.opportunityName = opportunityName
    opp
  }
}

@SerialVersionUID(1L)
@Entity
@Table(name = "opportunity")
class Opportunity extends Serializable {
  @Id
  @Column(name = "opportunity_id")
  @BeanProperty
  var opportunityId: String = _

  @Column(name = "opportunity_name")
  @BeanProperty
  var opportunityName: String = _

  @OneToOne
  @JoinColumn(name = "opportunity_id")
  var quote: Quote = _
}

object Quote {
  def apply(quoteId: String, opportunityId: String, quoteName: String): Quote = {
    val qte = new Quote
    qte.quoteId = quoteId
    qte.opportunityId = opportunityId
    qte.quoteName = quoteName
    qte
  }
}

@SerialVersionUID(1L)
@Entity
@Table(name = "quote")
class Quote extends Serializable {
  @Id
  @Column(name = "quote_id")
  @BeanProperty
  var quoteId: String = _

  @Column(name = "opportunity_id")
  @BeanProperty
  var opportunityId: String = _

  @Column(name = "quote_name")
  @BeanProperty
  var quoteName: String = _
}

CRUDをやるにあたって、上位クラスがいたので、それも少しアレンジして追加。
src/main/scala/javaee6/web/service/ServiceSupport.scala

package javaee6.web.service

import scala.annotation.tailrec
import scala.collection.JavaConverters._

import java.lang.reflect.ParameterizedType

import javax.persistence.{EntityManager, PersistenceContext}

trait PersistenceUnitSupport {
  var entityManager: EntityManager
}

trait StandardPersistenceUnitSupport extends PersistenceUnitSupport {
  @PersistenceContext(unitName = "javaee6.web.pu")
  var entityManager: EntityManager = _
}

abstract class ServiceSupport[T] {
  self: PersistenceUnitSupport =>

  val entityClass: Class[T] = findClass(getClass)

  @tailrec
  private def findClass[A](clazz: Class[_]): Class[A] =
    if (clazz.getSuperclass == classOf[ServiceSupport[_]])
      clazz
        .getGenericSuperclass
        .asInstanceOf[ParameterizedType]
        .getActualTypeArguments()
        .apply(0)
        .asInstanceOf[Class[A]]
    else
      findClass(clazz.getSuperclass)

  def create(entity: T): Unit =
    self.entityManager.persist(entity)

  def edit(entity: T): Unit =
    self.entityManager.merge(entity)

  def remove(entity: T): Unit =
    self.entityManager.remove(entityManager.merge(entity))

  def find(id: Any): T =
    self.entityManager.find(entityClass, id)

  def findAll: Iterable[T] =
    self.entityManager
      .createQuery(s"SELECT e FROM ${entityClass.getSimpleName} e")
      .getResultList
      .asScala
      .asInstanceOf[Iterable[T]]
}

変更点はEntityManagerの定義もこっちに持っていったのと、サブクラスのコンストラクタでエンティティのClassクラスを渡さないでいいようにしました。元はNetBeansの自動生成らしいのですが、こちらは普通に書いているので…。

もちろん、ムダにハマったけどね!

で、ステートレス・セッションBean。ちょっと確認のために、オペレーションをまとめたクラスがいます。引用元のブログにもいらっしゃいます。
src/main/scala/javaee6/web/service/Services.scala

src/main/scala/javaee6/web/service/Services.scala 
package javaee6.web.service

import scala.collection.JavaConverters._

import javax.ejb.{EJB, LocalBean, Stateless}

import javaee6.web.entity.{Opportunity, Quote}

@Stateless
@LocalBean
class TransactionService {
  @EJB
  var oppService: OpportunityService = _

  @EJB
  var qteService: QuoteService = _

  def saveOpportunityAndQuote(opp: Opportunity, qte: Quote): Unit = {
    qteService.create(qte)
    oppService.create(opp)
  }

  // saveQuoteメソッドの実装次第で、ロールバックするかどうかが決まる
  def unsafeOpportunityAndQuote(opp: Opportunity, qte: Quote): Unit = {
    try {
      saveQuote(qte)
    } catch {
      case e:Exception =>
        println(s"Exception Cause[$e]")
        e.printStackTrace
    }

    saveOpportunity(opp)
  }

  def saveOpportunity(opp: Opportunity): Unit =
    oppService.create(opp)

  def saveQuote(qte: Quote): Unit = {
    // qteService.unsafeCreate(qte)
    qteService.create(qte)
    throw new IllegalStateException("Opps!")
  }
}

@Stateless
@LocalBean
class OpportunityService extends ServiceSupport[Opportunity]
                         with StandardPersistenceUnitSupport {
  def findAllOrderById: Iterable[Opportunity] =
   entityManager
     .createQuery("SELECT o From Opportunity o ORDER BY o.opportunityId")
     .getResultList
     .asScala
     .asInstanceOf[Iterable[Opportunity]]
}

@Stateless
@LocalBean
class QuoteService extends ServiceSupport[Quote]
                   with StandardPersistenceUnitSupport {
  def unsafeCreate(qte: Quote): Unit = {
    create(qte)
    throw new IllegalStateException("Opps!")
  }
}

JAX-RSのリソースクラス。
src/main/scala/javaee6/web/jaxrs/TransactionSubmitResource.scala

package javaee6.web.jaxrs

import java.net.URI

import javax.inject.Inject
import javax.ws.rs.{Consumes, Encoded, GET, Path, POST, Produces}
import javax.ws.rs.core.{Context,  MediaType, MultivaluedMap, Response, UriBuilder, UriInfo}

import javaee6.web.entity.{Opportunity, Quote}
import javaee6.web.service.{OpportunityService, QuoteService, TransactionService}

@Path("/trans")
class TransactionSubmitResource {
  @Inject
  var transactionService: TransactionService = _

  @Inject
  var oppService: OpportunityService = _

  @Inject
  var qteService: QuoteService = _

  @GET
  @Path("index")
  @Produces(Array(MediaType.TEXT_HTML))
  def index(@Context uriInfo: UriInfo): Response = {
    val responseHtml =
      <html>
        <head>
          <meta charset="UTF-8" />
          <title>Jaxrs &amp; CMT</title>
        </head>
        <body>
          <h1>商談一覧</h1>
          <a href={uriInfo.getBaseUriBuilder.path("/trans/add").build().toASCIIString}>新規登録へ</a><br />
          <table border="1">
          <tr><th>商談ID</th><th>商談名</th><th>見積ID</th><th>見積名</th></tr>
          {oppService.findAllOrderById.map { o =>
            <tr>
              <td>{o.opportunityId}</td>
              <td>{o.opportunityName}</td>
              <td>{o.quote.quoteId}</td>
              <td>{o.quote.quoteName}</td>
            </tr>
          }}
          </table>
        </body>
      </html>

    Response.ok(responseHtml.toString).build
  }

  @GET
  @Path("add")
  @Produces(Array(MediaType.TEXT_HTML))
  def add(@Context uriInfo: UriInfo): Response = {
    val responseHtml =
      <html>
        <head>
          <meta charset="UTF-8" />
          <title>Jaxrs &amp; CMT</title>
        </head>
        <body>
          <form action={uriInfo.getBaseUriBuilder.path("/trans/submit").build().toASCIIString} method="post">
            商談ID: <input name="oppId" type="text" /><br />
            商談名: <input name="oppName" type="text" /><br />
            見積ID: <input name="qteId" type="text" /><br />
            見積名: <input name="qteName" type="text" /><br />
            <input type="submit" value="保存" />
          </form>
        </body>
      </html>

    Response.ok(responseHtml.toString).build
  }

  @POST
  @Path("submit")
  @Consumes(Array(MediaType.APPLICATION_FORM_URLENCODED))
  def submit(form: MultivaluedMap[String, String]): Response = {
    val opp = Opportunity(form.get("oppId").get(0),
                          form.get("oppName").get(0))
    val qte= Quote(form.get("qteId").get(0),
                   opp.opportunityId,
                   form.get("qteName").get(0))

    transactionService.saveOpportunityAndQuote(opp, qte)
    //transactionService.unsafeOpportunityAndQuote(opp, qte)

    Response
      .status(Response.Status.MOVED_PERMANENTLY)
      .location(new URI("/trans/index"))
      .build
  }
}

HTMLをベタッとScalaXMLリテラルで書いているのは、ご愛嬌…。

デプロイするリソースクラスを決定するクラス。
src/main/scala/javaee6/web/jaxrs/TransactionalApplication.scala

package javaee6.web.jaxrs

import scala.collection.JavaConverters._

import javax.ws.rs.ApplicationPath
import javax.ws.rs.core.Application

@ApplicationPath("rest")
class TransactionalApplication extends Application {
  override def getClasses: java.util.Set[Class[_]] =
    Set[Class[_]](classOf[TransactionSubmitResource]).asJava
}

あと、JAX-RSでHTML formのデータを受け取った時に、どうしても文字化けしちゃうので、Servlet Filterを書きました…。
src/main/scala/javaee6/web/filter/EncodingFilter.scala

package javaee6.web.filter

import javax.servlet.{Filter, FilterChain, FilterConfig, ServletRequest, ServletResponse}
import javax.servlet.annotation.WebFilter

@WebFilter(Array("/*"))
class EncodingFilter extends Filter {
  override def init(filterConfig: FilterConfig): Unit = ()

  override def destroy(): Unit = ()

  override def doFilter(req: ServletRequest, res: ServletResponse, chain: FilterChain): Unit = {
    req.setCharacterEncoding("UTF-8")
    chain.doFilter(req, res)
  }
}

JBoss ASのsystem-propertiesとかだと、うまく動かなかったで…。

で、これをデプロイして起動。

以下のURLにアクセスすると、
http://localhost:8080/javaee6-web/rest/trans/index

こういう画面が出るので、リンクを押して順次遷移していきます。



で、こんな感じで登録されたわけですが。

これで気になるのは、例外を投げたらロールバックされる点ですが、インターセプターがどこにかかっているかですね。なので、JAX-RSでの登録部分をこの後でこう変更。

    //transactionService.saveOpportunityAndQuote(opp, qte)
    transactionService.unsafeOpportunityAndQuote(opp, qte)

TransactionServiceクラスの該当の実装は、こうなっているので

  // saveQuoteメソッドの実装次第で、ロールバックするかどうかが決まる
  def unsafeOpportunityAndQuote(opp: Opportunity, qte: Quote): Unit = {
    try {
      saveQuote(qte)
    } catch {
      case e:Exception =>
        println(s"Exception Cause[$e]")
        e.printStackTrace
    }

    saveOpportunity(opp)
  }

  def saveOpportunity(opp: Opportunity): Unit =
    oppService.create(opp)

  def saveQuote(qte: Quote): Unit = {
    // qteService.unsafeCreate(qte)
    qteService.create(qte)
    throw new IllegalStateException("Opps!")
  }

この結果どうなるかということですが、結論からいうとロールバックしませんでした。

スタックトレースをさーっと見ると、こうなっていて

16:09:44,361 ERROR [stderr] (http--127.0.0.1-8080-3) java.lang.IllegalStateException: Opps!
16:09:44,362 ERROR [stderr] (http--127.0.0.1-8080-3) 	at javaee6.web.service.TransactionService.saveQuote(Services.scala:41)
16:09:44,362 ERROR [stderr] (http--127.0.0.1-8080-3) 	at javaee6.web.service.TransactionService.unsafeOpportunityAndQuote(Services.scala:27)
16:09:44,363 ERROR [stderr] (http--127.0.0.1-8080-3) 	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
16:09:44,363 ERROR [stderr] (http--127.0.0.1-8080-3) 	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:57)
16:09:44,363 ERROR [stderr] (http--127.0.0.1-8080-3) 	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
16:09:44,364 ERROR [stderr] (http--127.0.0.1-8080-3) 	at java.lang.reflect.Method.invoke(Method.java:606)
16:09:44,364 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.as.ee.component.ManagedReferenceMethodInterceptorFactory$ManagedReferenceMethodInterceptor.processInvocation(ManagedReferenceMethodInterceptorFactory.java:72)
16:09:44,366 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.invocation.InterceptorContext.proceed(InterceptorContext.java:288)
16:09:44,366 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.invocation.InterceptorContext$Invocation.proceed(InterceptorContext.java:374)
16:09:44,367 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.as.weld.ejb.Jsr299BindingsInterceptor.doMethodInterception(Jsr299BindingsInterceptor.java:127)
16:09:44,367 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.as.weld.ejb.Jsr299BindingsInterceptor.processInvocation(Jsr299BindingsInterceptor.java:135)
16:09:44,368 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.as.ee.component.interceptors.UserInterceptorFactory$1.processInvocation(UserInterceptorFactory.java:36)
16:09:44,368 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.invocation.InterceptorContext.proceed(InterceptorContext.java:288)
16:09:44,368 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.invocation.WeavedInterceptor.processInvocation(WeavedInterceptor.java:53)
16:09:44,369 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.as.ee.component.interceptors.UserInterceptorFactory$1.processInvocation(UserInterceptorFactory.java:36)
16:09:44,369 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.invocation.InterceptorContext.proceed(InterceptorContext.java:288)
16:09:44,370 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.as.jpa.interceptor.SBInvocationInterceptor.processInvocation(SBInvocationInterceptor.java:47)
16:09:44,370 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.invocation.InterceptorContext.proceed(InterceptorContext.java:288)
16:09:44,370 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.as.weld.ejb.EjbRequestScopeActivationInterceptor.processInvocation(EjbRequestScopeActivationInterceptor.java:82)
16:09:44,371 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.invocation.InterceptorContext.proceed(InterceptorContext.java:288)
16:09:44,374 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.invocation.InitialInterceptor.processInvocation(InitialInterceptor.java:21)
16:09:44,380 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.invocation.InterceptorContext.proceed(InterceptorContext.java:288)
16:09:44,380 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.invocation.ChainedInterceptor.processInvocation(ChainedInterceptor.java:61)
16:09:44,381 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.as.ee.component.interceptors.ComponentDispatcherInterceptor.processInvocation(ComponentDispatcherInterceptor.java:53)
16:09:44,381 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.invocation.InterceptorContext.proceed(InterceptorContext.java:288)
16:09:44,382 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.as.ejb3.component.pool.PooledInstanceInterceptor.processInvocation(PooledInstanceInterceptor.java:51)
16:09:44,382 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.invocation.InterceptorContext.proceed(InterceptorContext.java:288)
16:09:44,382 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.as.ejb3.tx.CMTTxInterceptor.invokeInOurTx(CMTTxInterceptor.java:228)
16:09:44,388 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.as.ejb3.tx.CMTTxInterceptor.required(CMTTxInterceptor.java:304)
16:09:44,388 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.as.ejb3.tx.CMTTxInterceptor.processInvocation(CMTTxInterceptor.java:190)
16:09:44,397 ERROR [stderr] (http--127.0.0.1-8080-3) 	at org.jboss.invocation.InterceptorContext.proceed(InterceptorContext.java:288)

コンテナ管理のトランザクションになっているの、最初のEJBメソッドコールだけですね。次は、普通のメソッド呼び出しです。

16:09:44,361 ERROR [stderr] (http--127.0.0.1-8080-3) java.lang.IllegalStateException: Opps!
16:09:44,362 ERROR [stderr] (http--127.0.0.1-8080-3) 	at javaee6.web.service.TransactionService.saveQuote(Services.scala:41)
16:09:44,362 ERROR [stderr] (http--127.0.0.1-8080-3) 	at javaee6.web.service.TransactionService.unsafeOpportunityAndQuote(Services.scala:27)

Seasar2AOPを使ったトランザクションだと、この手のパターンもロールバックマークされていたので、なるほどなーって感じでした。

ここでもうひとつ、saveQuoteメソッドの実装をこのように変えて試してみました。

  def saveQuote(qte: Quote): Unit = {
    qteService.unsafeCreate(qte)
    // qteService.create(qte)
    // throw new IllegalStateException("Opps!")
  }

ここで、QuoteService#unsafeCreateメソッドの実装は、このような形になっています。

  def unsafeCreate(qte: Quote): Unit = {
    create(qte)
    throw new IllegalStateException("Opps!")
  }

この場合、ロールバックします。しますが、だいぶグシャっといきます。最初に、QuoteService#unsafeCreateにインターセプトされたトランザクションが、ロールバックされることが決まります。

22:47:51,084 INFO  [stdout] (http--127.0.0.1-8080-1) Exception Cause[javax.ejb.EJBTransactionRolledbackException: Opps!]
22:47:51,085 ERROR [stderr] (http--127.0.0.1-8080-1) javax.ejb.EJBTransactionRolledbackException: Opps!
22:47:51,086 ERROR [stderr] (http--127.0.0.1-8080-1) 	at org.jboss.as.ejb3.tx.CMTTxInterceptor.handleInCallerTx(CMTTxInterceptor.java:139)
22:47:51,087 ERROR [stderr] (http--127.0.0.1-8080-1) 	at org.jboss.as.ejb3.tx.CMTTxInterceptor.invokeInCallerTx(CMTTxInterceptor.java:204)
22:47:51,099 ERROR [stderr] (http--127.0.0.1-8080-1) 	at org.jboss.as.ejb3.tx.CMTTxInterceptor.required(CMTTxInterceptor.java:306)
22:47:51,099 ERROR [stderr] (http--127.0.0.1-8080-1) 	at org.jboss.as.ejb3.tx.CMTTxInterceptor.processInvocation(CMTTxInterceptor.java:190)

その後、OppotunityService#createを呼び出した時に、すでにトランザクションロールバックとしてマークされているため、ここでのメソッド呼び出しが失敗します。

22:47:51,274 ERROR [org.jboss.as.ejb3.tx.CMTTxInterceptor] (http--127.0.0.1-8080-1) javax.ejb.EJBTransactionRolledbackException: JBAS011469: Transaction is required to perform this operation (either use a transaction or extended persistence context)
22:47:51,275 ERROR [org.jboss.ejb3.invocation] (http--127.0.0.1-8080-1) JBAS014134: EJB Invocation failed on component OpportunityService for method public void javaee6.web.service.ServiceSupport.create(java.lang.Object): javax.ejb.EJBTransactionRolledbackException: JBAS011469: Transaction is required to perform this operation (either use a transaction or extended persistence context)
	at org.jboss.as.ejb3.tx.CMTTxInterceptor.handleInCallerTx(CMTTxInterceptor.java:139) [jboss-as-ejb3-7.1.1.Final.jar:7.1.1.Final]
	at org.jboss.as.ejb3.tx.CMTTxInterceptor.invokeInCallerTx(CMTTxInterceptor.java:204) [jboss-as-ejb3-7.1.1.Final.jar:7.1.1.Final]
	at org.jboss.as.ejb3.tx.CMTTxInterceptor.required(CMTTxInterceptor.java:306) [jboss-as-ejb3-7.1.1.Final.jar:7.1.1.Final]
	at org.jboss.as.ejb3.tx.CMTTxInterceptor.processInvocation(CMTTxInterceptor.java:190) [jboss-as-ejb3-7.1.1.Final.jar:7.1.1.Final]
	at org.jboss.invocation.InterceptorContext.proceed(InterceptorContext.java:288) [jboss-invocation-1.1.1.Final.jar:1.1.1.Final]
	at org.jboss.as.ejb3.component.interceptors.CurrentInvocationContextInterceptor.processInvocation(CurrentInvocationContextInterceptor.java:41) [jboss-as-ejb3-7.1.1.Final.jar:7.1.1.Final]

ちなみに、念のため

  // saveQuoteメソッドの実装次第で、ロールバックするかどうかが決まる
  def unsafeOpportunityAndQuote(opp: Opportunity, qte: Quote): Unit = {
    try {
      saveQuote(qte)
    } catch {
      case e:Exception =>
        println(s"Exception Cause[$e]")
        e.printStackTrace
    }

    //saveOpportunity(opp)
  }

というように、OpportunityService#createの呼び出しをコメントアウトしても、やはりロールバックします。この場合は、ファサードとなっているTransactionService#unsafeOppotunityAndQuoteを例外で抜けなくてもロールバックするわけです。

こちらの動きは、なんとなく思っていた通りでした。まあ、最初のパターンがロールバックされなかった時点で、予想がつきますね…。

というわけで、EJBインスタンスに対してメソッド呼び出しを行うことで、EJBのプロキシ越しにアクセスした場合にトランザクションが開始される(インターセプトされる)という理解で、少なくともEJB Liteの場合はよさそうです。EJB内のメソッドから、自クラスのメソッドを呼び出す場合は、プロキシではなく実インスタンスを触っているということですね。

EJBをリモート呼び出しする場合は、また事情が変わるのかもしれませんが、今回は対象外とします。Java EE 7になったら、EJB触るかどうかわからないし…。

とはいえ、実際に試してみるとやっぱり勉強になりますね。