原文はこちら。
The original article was written by Daniel Kec (Java Developer at Oracle).
https://medium.com/helidon/long-running-actions-with-helidon-573b296017d1
https://danielkec.github.io/blog/helidon/lra/saga/2021/10/12/helidon-lra.html
マイクロサービス環境で一貫性を実現するための、ロックフリーで疎結合なアプローチの待望の仕様であるMicroProfile Long Running Actions (以下、LRA) が、ついにバージョン1.0としてリリースされました。Helidon MPではLRA 1.0のサポートを、最新のリリース2.4.0から提供します。
MicroProfile LRA
https://download.eclipse.org/microprofile/microprofile-lra-1.0/microprofile-lra-spec-1.0.html
Helidon 2.4.0 released!
https://medium.com/helidon/helidon-2-4-0-released-18370c0ebc5e
https://logico-jp.io/2021/11/16/helidon-2-4-0-released/
LRAは緩く分離された分散トランザクションの代替であり、完全にJAX-RSに統合されています。トランザクショナルなBeanメソッドではなく、トランザクショナルなJAX-RSメソッドです。
LRAはSAGAパターンを踏襲しており、非同期の補正(補償)を使って、最終的なデータの整合性(結果整合性)を維持できます。
Long-running transaction
https://en.wikipedia.org/wiki/Long-running_transaction
コストの高い分離をステージングする必要はありません。この方法は、データの整合性を監視するための追加の負担を取り除き、マイクロサービスの世界で高く評価されている機能である高スケーラビリティを提供します。
LRA Transaction
複数の参加者がすべてのLRAトランザクションに参加できます。参加者 (participants) は、LRA固有のアノテーションで注釈が付けられたメソッドを持つJAX-RSリソースです。これらのアノテーションを使って、@LRA
と他の参加者を結合するので、トランザクションの補償時 (@Compensate
) やトランザクションの完了時 (@Complete
) に一緒に呼び出すことができます。
@Path("/example")
@ApplicationScoped
public class LRAExampleResource {
@PUT
@LRA(value = LRA.Type.REQUIRES_NEW, timeLimit = 500, timeUnit = ChronoUnit.MILLIS)
@Path("start-example")
public Response startExample(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId, String data) {
// Executed in the scope of new LRA transaction
return Response.ok().build();
}
@PUT
@Complete
@Path("complete-example")
public Response completeExample(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId) {
// Called by LRA coordinator when startExample method sucessfully finishes
return LRAResponse.completed();
}
@PUT
@Compensate
@Path("compensate-example")
public Response compensateExample(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId) {
// Called by LRA coordinator when startExample method throws exception or don't finish before time limit
return LRAResponse.compensated();
}
}
LRAトランザクションに参加するすべての参加者は、 @Compensate
、 @Complete
、 @AfterLRA
などでアノテーションされたリソースにつながるURLである、補償リンク (compensation link) を提供する必要があります。LRAコーディネーターは、LRAトランザクションの状態が変化したときに、どのリソースを呼び出すかを追跡します。JAX-RSリソース・メソッドに @LRA(REQUIRES_NEW)
が付いている場合、インターセプトされたすべての呼び出しは、リソース・メソッドが呼び出される前に、コーディネーター内で新しいLRAトランザクションを開始し、新しい参加者としてそれに参加します。作成されたLRAトランザクションのIDは、リソースメソッドでLong-Running-Actionヘッダを通じてアクセスできます。リソース・メソッドの呼び出しが正常に終了すると、コーディネーターに対し、LRAトランザクションがクローズされたと報告されます。参加者が @Complete
メソッドを持っている場合、最終的にはコーディネーターが適切なLRA IDヘッダーと、LRAトランザクション内に参加している他の参加者全員の @Complete
メソッドを使って再度呼び出します。

リソースメソッドが例外を吐いて終了する場合、コーディネーターにLRAがキャンセルされたと報告され、コーディネーターはそのトランザクションの下で登録されたすべての参加者に@Compensate
メソッドを呼び出します。

トランザクションがタイムアウト前にクローズされなかった場合、コーディネーターはトランザクションをキャンセルし、タイムアウトしたトランザクションのすべての参加者の補償エンドポイントを呼び出します。

LRA Coordinator
HelidonのLong Running Actionsの実装では、クラスター全体でLRAを編成するためにLRAコーディネーターが必要です。これは追加のサービスなので、クラスタでLRA機能を有効にする必要があります。LRAコーディネーターは、どの参加者がどのLRAトランザクションに参加したかを追跡し、LRAトランザクションが完了またはキャンセルされると、参加者のLRA補償リソースを呼び出します。
Helidonでは以下をサポートしています。
- Narayana LRA Coordinator
- 実験的な Helidon LRA Coordinator
Narayana LRA Coordinator
Narayanaは、Arjuna Coreを中心に構築されており、分散型トランザクションの分野で長く信頼されている、有名なトランザクションマネージャです。Narayana LRA CoordinatorはLong Running Actionsのサポートをもたらし、市場で最初のLRA Coordinatorです。
wget https://search.maven.org/remotecontent?filepath=org/jboss/narayana/rts/lra-coordinator-quarkus/5.11.1.Final/lra-coordinator-quarkus-5.11.1.Final-runner.jar \
-O narayana-coordinator.jar \
&& java -Dquarkus.http.port=8070 -jar narayana-coordinator.jar
Experimental Helidon LRA Coordinator
Helidonには、開発やテストの目的で簡単に設定できる独自の実験用コーディネーターがあります。本番環境での使用はお勧めできませんが、LRAリソースのテストに適した軽量なソリューションです。
docker build -t helidon/lra-coordinator https://github.com/oracle/helidon.git#:lra/coordinator/server
docker run -dp 8070:8070 --name lra-coordinator --network="host" helidon/lra-coordinator
具体的なユースケースを見てみましょう。
Online Cinema Booking System
仮想的な映画館では、オンライン予約システムを必要としています。オンライン予約システムを2つのスケーラブルなサービス(座席の予約と支払い)に分割します。これらのサービスは完全に分離されており、統合はREST APIの呼び出しを使うのみです。
この予約サービスは、最初に座席を予約します。予約サービスはLRAトランザクションを新規で開始し、最初のトランザクション参加者としてトランザクションに参加します。LRAコーディネーターとのすべてのコミュニケーションは裏側で行われ、LRA IDを使ってアクセスできます。このIDは、リクエストヘッダーLong-Running-Action
として、JAX-RSメソッドの新しいトランザクションに割り当てられています。なお、Lra#end
がfalse
に設定されているため、JAX-RSメソッドが終了した後もLRAはアクティブなままであることに注意してください。
@PUT
@Path("/create/{id}")
// Create new LRA transaction which won't end after this JAX-RS method end
// Time limit for new LRA is 30 sec
@LRA(value = LRA.Type.REQUIRES_NEW, end = false, timeLimit = 30)
@Produces(MediaType.APPLICATION_JSON)
public Response createBooking(@HeaderParam(LRA.LRA_HTTP_CONTEXT_HEADER) URI lraId,
@PathParam("id") long id,
Booking booking) {
// LRA ID assigned by coordinator is provided as artificial request header
booking.setLraId(lraId.toASCIIString());
if (repository.createBooking(booking, id)) {
LOG.info("Creating booking for " + id);
return Response.ok().build();
} else {
LOG.info("Seat " + id + " already booked!");
return Response
.status(Response.Status.CONFLICT)
.entity(JSON.createObjectBuilder()
.add("error", "Seat " + id + " is already reserved!")
.add("seat", id)
.build())
.build();
}
}

座席の予約に成功すると、支払いサービスが同じLRAトランザクションの配下で呼び出されます。レスポンスには、クライアントからアクセスできるように、ヘッダLong-Running-Action
が含まれています。
reserveButton.click(function () {
selectionView.hide();
createBooking(selectedSeat.html())
.then(res => {
if (res.ok) {
// Notice how we can access LRA ID even on the client side
let lraId = res.headers.get("Long-Running-Action");
paymentView.attr("data-lraId", lraId);
paymentView.show();
} else {
res.json().then(json => {
showError(json.error);
});
}
});
});
別のバックエンドリソースを、Long-Running-Actionヘッダーを再度設定することで、同じLRAトランザクションを使って呼び出すことも可能です。
function makePayment(cardNumber, amount, lraId) {
return fetch('/booking/payment', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Long-Running-Action': lraId
},
body: JSON.stringify({"cardNumber": cardNumber, "amount": amount})
})
}

バックエンドは異なるサービスをJAX-RSクライアントを使って呼び出しますが、Long-Running-Actionヘッダーを設定してLRAトランザクションを伝播する必要はありません。すべてのJAX-RSクライアントの場合と同じく、LRA実装が自動的にやってくれます。
@PUT
@Path("/payment")
// Needs to be called within LRA transaction context
// Doesn't end LRA transaction
@LRA(value = LRA.Type.MANDATORY, end = false)
@Produces(MediaType.APPLICATION_JSON)
public Response makePayment(@HeaderParam(LRA.LRA_HTTP_CONTEXT_HEADER) URI lraId,
JsonObject jsonObject) {
LOG.info("Payment " + jsonObject.toString());
// Notice that we don't need to propagate LRA header
// When using JAX-RS client, LRA header is propagated automatically
ClientBuilder.newClient()
.target("http://payment-service:7002")
.path("/payment/confirm")
.request()
.rx()
.put(Entity.entity(jsonObject, MediaType.APPLICATION_JSON))
.whenComplete((res, t) -> {
if (res != null) {
LOG.info(res.getStatus() + " " + res.getStatusInfo().getReasonPhrase());
res.close();
}
});
return Response.accepted().build();
}
支払いサービスが別の参加者としてこのトランザクションに参加します。0000-0000-0000
以外の任意のカード番号でLRAトランザクションをキャンセルします。リソースメソッドが終了すると、LRA#end
がtrue
に設定されるため、LRAトランザクションが完了します。
@PUT
@Path("/confirm")
// This resource method ends/commits LRA transaction as successfully completed
@LRA(value = LRA.Type.MANDATORY, end = true)
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public Response makePayment(@HeaderParam(LRA.LRA_HTTP_CONTEXT_HEADER) URI lraId,
Payment payment) {
if (!payment.cardNumber.equals("0000-0000-0000")) {
LOG.warning("Payment " + payment.cardNumber);
throw new IllegalStateException("Card " + payment.cardNumber + " is not valid! "+lraId);
}
LOG.info("Payment " + payment.cardNumber+ " " +lraId);
return Response.ok(JSON.createObjectBuilder().add("result", "success").build()).build();
}
支払いのオペレーションが失敗したり、タイムアウトが発生したりした場合、LRAトランザクションはキャンセルされ、トランザクション参加時に提供された補償リンク使ってすべての参加者に通知されます。LRAコーディネーターは @Compensate
で注釈が付けられたメソッドをLRA idをパラメータとして付けて呼び出します。これだけで、予約サービスで座席の予約を解除して、別のお客様の利用に供することができます。
@Compensate
public Response paymentFailed(URI lraId) {
LOG.info("Payment failed! " + lraId);
repository.clearBooking(lraId)
.ifPresent(booking -> {
LOG.info("Booking for seat " + booking.getSeat().getId() + "cleared!");
Optional.ofNullable(sseBroadcaster)
.ifPresent(b -> b.broadcast(new OutboundEvent.Builder()
.data(booking.getSeat())
.mediaType(MediaType.APPLICATION_JSON_TYPE)
.build())
);
});
return Response.ok(ParticipantStatus.Completed.name()).build();
}

LRAを使ったCinema Bookingサンプルプロジェクトは以下のリポジトリからアクセスできます。
Helidon MicroProfile LRA example
https://github.com/danielkec/helidon-lra-example
このサンプルプロジェクトでは、Oracle Kubernetes EngineもしくはローカルのMinikubeにデプロイできるよう、簡単なKubernetes (k8s) サービスの設定を用意しています。
Minikube
https://minikube.sigs.k8s.io/docs/
Deploy to minikube
前提条件
- minikubeをインストール、起動済みであること
- minikubeのDockerデーモンがある環境
eval $(minikube docker-env)
Pushing directly to the in-cluster Docker daemon (docker-env)
https://minikube.sigs.k8s.io/docs/handbook/pushing/#1-pushing-directly-to-the-in-cluster-docker-daemon-docker-env
Build images
minikubeのDockerデーモンを直接操作するので、やるべきことはDockerイメージのビルドだけです。
bash build.sh;
最初のビルドの場合、すべてのアーティファクトをダウンロードするため数分かかる点にご注意ください。以後のビルドでは、依存関係のあるレイヤーをキャッシュ済みなのでビルドはずっと高速です。
Deploy to minikube
bash deploy-minikube.sh
このスクリプトは名前空間全体を再作成し、 cinema-reservation
の以前の状態すべてを消去します。デプロイはNodePortで公開され、ポート付きのURLを出力の最後に出します。
namespace "cinema-reservation" deleted
namespace/cinema-reservation created
Context "minikube" modified.
service/booking-db created
service/lra-coordinator created
service/payment-service created
service/seat-booking-service created
deployment.apps/booking-db created
deployment.apps/lra-coordinator created
deployment.apps/payment-service created
deployment.apps/seat-booking-service created
service/cinema-reservation exposed
Application cinema-reservation will be available at http://192.0.2.254:31584
Deploy to OCI OKE cluster
前提条件
- OKE K8s cluster
- Git、docker、Oracle Container Engine for Kubernetes (OKE) クラスタにアクセスできるよう構成済みのkubectlを持つを備えたOCI Cloud Shell
Pushing images to your OCI Container registry
まず最初に、Dockerイメージをプッシュして、OKE K8sがプルできるようにするための場が必要です。コンテナレジストリはOCIテナントの一部なので、ログインが必要です。
docker login <REGION_KEY>.ocir.io
レジストリのユーザー名は
<TENANCY_NAMESPACE>/joe@example.com
です。ここで joe@example.com
がOCIユーザーだとします。パスワードは joe@example.com
の認可トークンです。リージョンキーとテナント名前空間を取得するには、以下のコマンドをOCI Cloud Shellで実行します。
# Get tenancy namespace and container registry
echo "" && \
echo "Container registry: ${OCI_CONFIG_PROFILE}.ocir.io" && \
echo "Tenancy namespace: $(oci os ns get --query "data" --raw-output)" && \
echo "" && \
echo "docker login ${OCI_CONFIG_PROFILE}.ocir.io" && \
echo "Username: $(oci os ns get --query "data" --raw-output)/joe@example.com" && \
echo "Password: --- Auth token for user joe@example.com" && \
echo ""
以下のような出力が出ます。
Container registry: eu-frankfurt-1.ocir.io
Tenancy namespace: fr8yxyel2vcv
docker login eu-frankfurt-1.ocir.io
Username: fr8yxyel2vcv/joe@example.com
Password: --- Auth token for user joe@example.com
コンテナレジストリ、名前空間、認可トークンは後の作業のために保存しておきましょう。
ローカルのDockerでOCI Container Registryにログインしている場合、パラメータとしてコンテナレジストリとテナント名前空間を使い、 build-oci.sh
を実行できます。
以下は実行例です。
bash build-oci.sh eu-frankfurt-1.ocir.io fr8yxyel2vcv
出力結果は以下のようになります。
docker build -t eu-frankfurt-1.ocir.io/fr8yxyel2vcv/cinema-reservation/payment-service:1.0 .
...
docker push eu-frankfurt-1.ocir.io/fr8yxyel2vcv/cinema-reservation/seat-booking-service:1.0
...
docker build -t eu-frankfurt-1.ocir.io/fr8yxyel2vcv/cinema-reservation/seat-booking-service:1.0 .
...
docker push eu-frankfurt-1.ocir.io/fr8yxyel2vcv/cinema-reservation/payment-service:1.0
...
スクリプトは実行前にdocker build
コマンドを表示します。最初のビルドではすべてのアーティファクトをダウンロードするために数分かかる点にご注意ください。以後のビルドでは依存関係のレイヤーをキャッシュ済みなのでずっと高速にビルドします。
プッシュしたイメージを公開するためには、OCIコンソースを開いて両リポジトリをPublicに設定します。
Developer Tools>Containers & Artifacts> Container Registry

Deploy to OKE
OCI Cloud Shellでクローンされた helidon-lra-example リポジトリを、K8s descriptorと一緒に利用できます。以前の手順でプッシュしたイメージに変更を加えます。
OCI Cloud shellでの操作例です。
git clone https://github.com/danielkec/helidon-lra-example.git
cd helidon-lra-example
bash deploy-oci.sh
kubectl get services
出力結果は以下のようになります。
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
booking-db ClusterIP 192.0.2.254 <none> 3306/TCP 34s
lra-coordinator NodePort 192.0.2.253 <none> 8070:32434/TCP 33s
oci-load-balancing-service LoadBalancer 192.0.2.252 <pending> 80:31192/TCP 33s
payment-service NodePort 192.0.2.251 <none> 8080:30842/TCP 32s
seat-booking-service NodePort 192.0.2.250 <none> 8080:32327/TCP 32s
デプロイの直後、OCI がプロビジョニングを行っているため、外部ロードバランサーの EXTERNAL-IP
が <pending>
になっていることがわかります。少し後にkubectl get services
を実行すると、Helidon Cinema exampleが80番ポートで公開されている外部IPアドレスが表示されていることがわかります。
Conclusion
分散システムの整合性を補償ロジックで維持するというアイデアは新しいものではありませんが、特別なツールなしに実現するのは非常に複雑です。MicroProfile Long Running Actionsはまさにそのようなツールであり、複雑さを隠蔽してくれるので、ビジネスロジックに集中できるようにしてくれます。
現在、他のLRAコーディネーターとの互換性や、メッセージングにおけるLRAコンテキストのサポートなど、さらなるエキサイティングな機能にすでに取り組んでいます。
是非ご期待ください。
Resources
- Helidon LRA documentation
https://helidon.io/docs/v2/#/mp/lra/01_introduction - MicroProfile Long Running Actions Specification
https://download.eclipse.org/microprofile/microprofile-lra-1.0/microprofile-lra-spec-1.0.html - Narayana LRA coordinator
https://narayana.io/lra/ - Online Cinema Booking example project
https://github.com/danielkec/helidon-lra-example