Step 05 - Resilient Agentic Workflows with Persistence
The Miles of Smiles team has been trying out the approval flow from Step 04, and everything works well until the application needs to restart, scale up/down, or a pod gets rescheduled to a different node on their Kubernetes cluster.
A customer who was still reading their itinerary comes back to an empty form, with no way to approve the trip they had just generated. Although the agents had finished their work, the plan and its pending approval were only held in memory.
We’ll address this resilience issue by saving enough information for the customer to continue where they left off. Along the way, we’ll see how Quarkus Flow restores a waiting workflow and how we can store the trip and workflow state in a database .
Workflow state and application state
For the application to be resilient, the application needs to remember both where it paused and what it was showing the customer*. These are saved separately in the same database, so the workflow can continue waiting for approval while the browser retrieves the trip details.
flowchart TD
workflow["Approval workflow"]
store["Trip plan store"]
browser["Results page"]
subgraph database["PostgreSQL"]
progress[("Workflow progress")]
trip[("Trip details")]
end
workflow -->|Saves and restores| progress
store -->|Saves and reads| trip
browser -->|Requests the plan| store
Quarkus Flow handles the workflow side through its persistence extension, while our application uses Hibernate for the trip records.
Preparing the working copy
Apply the changes below to your working project. Keep your existing model-provider settings and dependencies. Dev mode restarts automatically when it detects the pom.xml change; the first restart will take longer than usual because Dev Services is now starting a PostgreSQL container.
Copy section-3/step-05 to a working directory and open that copy. Apply your existing model-provider settings, keeping the supplied persistence configuration. The code changes below are already included, so you can follow along. Configure container reuse before starting the application, then join the hands-on route at Verifying workflow recovery after a restart.
Configuring PostgreSQL
Quarkus can start PostgreSQL for us through Dev Services, so the database starts together with the application. We do need to add the driver and the Flow persistence extension, then make sure the database is kept when the application stops.
Run the following Maven command to add the PostgreSQL driver and the Flow persistence extension:
The existing Quarkus Flow BOM keeps quarkus-flow-jpa on the same version as the other Flow extensions. quarkus-flow-jpa also brings in Hibernate ORM with Panache, which we’ll use to save the trip plan.
Add the database and workflow persistence settings to src/main/resources/application.properties:
# PostgreSQL stores both Flow checkpoints and the customer-facing trip state.
quarkus.datasource.db-kind=postgresql
# Trip JSON contains plain records, independent of the REST/CloudEvent mapper customizations.
quarkus.hibernate-orm.mapping.format.global=ignore
%dev.quarkus.hibernate-orm.schema-management.strategy=update
quarkus.flow.persistence.auto-restore=true
The two Hibernate settings support the exercise:
%dev.quarkus.hibernate-orm.schema-management.strategy=updatelets Hibernate create the tables and keep their contents across dev-mode startups. Its usual Dev Services setting,drop-and-create, would leave us with an empty database every time we restarted.quarkus.hibernate-orm.mapping.format.global=ignorekeeps Hibernate’s JSON serialization of our records independent of the application’s REST and CloudEvent mapper customizations.
For a production application, an explicitly configured datasource and controlled schema migrations would be a better choice than letting Hibernate update the schema automatically.
Keeping PostgreSQL between restarts
Keeping the tables is only useful if the next run connects to the same database. We also need to enable container reuse so that Testcontainers, which Dev Services uses to run PostgreSQL, keeps the database container available across application restarts.
Choose one of the following ways to enable container reuse before starting or restarting dev mode.
Set the variable in the shell you are using to run the app:
Add this line to .envrc in the project directory:
direnv allow to activate it.
Add this line to .testcontainers.properties in your home directory, creating the file if needed:
Keep the same database
Keep the datasource settings unchanged between runs and leave the PostgreSQL container running until the exercise is complete. If the application starts with a fresh container, the tables will be empty even if the previous run saved its data correctly.
Persisting and restoring Quarkus Flow instances
With the database ready, Flow can save a waiting workflow and restore it when the application starts again. The quarkus.flow.persistence.auto-restore=true setting added above makes the default restoration behavior explicit. On startup, Flow reloads the saved workflow and goes straight back to waiting for the customer’s decision, with the plan the agents generated before the restart.
Does this also save the agents’ working state?
The restart exercise begins after the agents have generated the plan, while the workflow is waiting for approval. At that point, restoring the workflow and its completed plan is enough to continue to simulated booking. LangChain4j’s shared AgenticScope is only needed while the agents are running, so it stays in memory.
Saving that scope is a separate feature, with its own serialization and recovery logic through AgenticScopeSerializer and a deserialization-package registration. You would need it to resume a workflow in the middle of an agent’s execution.
Persisting application state with Hibernate ORM and Panache
Flow can now resume the approval process, but the browser also needs the customer’s plan after a restart. We will save the trip details in PostgreSQL so the application can return the same plan when the browser reloads.
Why not read the plan back from Kafka?
The outcome events already carry the request and plan, and Kafka can retain them after the application stops. But a restarted consumer resumes from its committed offset and only reads new events. Restoring the waiting workflow also takes Flow’s own checkpoint, which lives in PostgreSQL, so we store the trip records in the same database.
Defining a Panache entity
Each trip needs a database row that keeps the customer’s request and plan available after a restart.
Create src/main/java/com/tripplanner/model/TripPlanEntity.java:
package com.tripplanner.model;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.SequenceGenerator;
import jakarta.persistence.Table;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
@Entity
@Table(name = "trip_plan_status", indexes = @Index(name = "trip_status_idx", columnList = "status"))
public class TripPlanEntity extends PanacheEntityBase {
// No per-JVM ID blocks: registration order must remain stable across application instances.
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "trip_plan_sequence")
@SequenceGenerator(name = "trip_plan_sequence", sequenceName = "trip_plan_sequence", allocationSize = 1)
public Long id;
@Column(unique = true, nullable = false)
public String requestId;
@Column(unique = true)
public String instanceId;
@Column(nullable = false)
public String status;
@JdbcTypeCode(SqlTypes.JSON)
@Column(nullable = false)
public TripRequest request;
@JdbcTypeCode(SqlTypes.JSON)
public TripPlan plan;
@JdbcTypeCode(SqlTypes.JSON)
public BookingConfirmation confirmation;
@JdbcTypeCode(SqlTypes.JSON)
public TripApproval acceptedDecision;
public String error;
@Column(columnDefinition = "TEXT")
public String message;
}
requestId identifies the trip before Flow starts. The same row picks up an instanceId when the workflow is created, and later the plan, the accepted decision, and the outcome. @JdbcTypeCode(SqlTypes.JSON) stores the request, plan, confirmation, and decision as JSON columns while keeping them as typed Java fields, and the saved request is what lets the browser restore the form and page header.
The entity extends PanacheEntityBase so it can define its own ID. allocationSize = 1 stops each application instance from reserving its own block of IDs, which keeps the ordering we use to find the latest request.
The Hibernate ORM with Panache guide has more about entity mapping and queries.
Adding transactional persistence and queries
The store will continue receiving the same events and returning the same TripPlanStatus envelope, including both identifiers and the original request. The edits below move its reads and writes into the database, with changed lines highlighted. All of them are in the store and its new entity.
Open src/main/java/com/tripplanner/agentic/flow/TripPlanStore.java. Remove the requests, instances, and decisions maps, the latestRequestId field, and the HashMap and Map imports. Update the imports as highlighted below, keeping the logger and injected ObjectMapper:
import java.time.Duration;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.TimeUnit;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Message;
import org.jboss.logging.Logger;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tripplanner.model.PlanningRequest;
import com.tripplanner.model.TripApproval;
import com.tripplanner.model.TripError;
import com.tripplanner.model.TripPlanEntity;
import com.tripplanner.model.TripPlanStatus;
import com.tripplanner.model.TripRequest;
import io.serverlessworkflow.impl.lifecycle.WorkflowExecutionListener;
import io.serverlessworkflow.impl.lifecycle.WorkflowFailedEvent;
import io.smallrye.reactive.messaging.annotations.Blocking;
import io.smallrye.reactive.messaging.ce.CloudEventMetadata;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.LockModeType;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import static jakarta.transaction.Transactional.TxType.REQUIRES_NEW;
@ApplicationScoped
public class TripPlanStore implements WorkflowExecutionListener {
private static final Logger LOG = Logger.getLogger(TripPlanStore.class);
@Inject
ObjectMapper objectMapper;
The in-memory implementation used synchronized methods to protect those maps. Database transactions and locks will now protect the records, including when more than one application instance connects to the same database.
Replace register() and bind() with the following versions, removing their synchronized modifiers:
@Transactional(REQUIRES_NEW)
public PlanningRequest register(TripRequest request) {
TripPlanEntity entity = new TripPlanEntity();
entity.requestId = UUID.randomUUID().toString();
entity.request = request;
entity.status = "planning";
entity.persist();
return new PlanningRequest(entity.requestId, request);
}
// Bind the persisted request before any model work or result publication.
@Transactional(REQUIRES_NEW)
public TripPlanStatus bind(PlanningRequest input, String instanceId) {
TripPlanEntity entity = TripPlanEntity.find("requestId", input.requestId())
.withLock(LockModeType.PESSIMISTIC_WRITE).firstResult();
if (entity == null || !"planning".equals(entity.status) || entity.instanceId != null
|| !Objects.equals(input.request(), entity.request)) {
throw new IllegalStateException("Unknown or duplicate planning request");
}
entity.instanceId = Objects.requireNonNull(instanceId);
return toStatus(entity);
}
register() commits the request before the REST resource publishes the planning event. When Flow starts, bind() locks that trip’s row to check the request and attach its workflow identifier before the agents run. Other trips have separate rows, so they can be planned at the same time.
Committing outcomes before acknowledging events
The store must save each outcome before acknowledging its Kafka event. If the database commit fails, the event is negatively acknowledged and its Kafka offset stays uncommitted.
flowchart TD
event[Outcome event] --> save[Lock trip row and save outcome]
save --> commit{Commit succeeds?}
commit -->|Yes| ack[Acknowledge event]
commit -->|No| nack[Negative acknowledgement]
Add @Blocking to consume() and the highlighted comment, keeping its event names, dispatch, and exception handling unchanged:
@Incoming("flow-out-consumer")
@Blocking
public CompletionStage<Void> consume(Message<String> message) {
CloudEventMetadata<?> event = message.getMetadata(CloudEventMetadata.class).orElse(null);
if (event == null) return message.ack();
String instanceId = event.<String>getExtension("flowinstanceid").orElse(null);
String state = switch (event.getType()) {
case "com.tripplanner.trip.approval.requested" -> "awaiting_approval";
case "com.tripplanner.booking.finalized" -> "confirmed";
case "com.tripplanner.trip.rejected" -> "rejected";
case "com.tripplanner.trip.failed" -> "failed";
default -> null;
};
if (instanceId == null || state == null) return message.ack();
try {
accept(instanceId, state, objectMapper.readValue(message.getPayload(), TripPlanStatus.class));
} catch (JsonProcessingException e) {
LOG.warn("Ignoring malformed trip outcome event");
}
// accept() commits before acknowledgement. Database failures escape to messaging's nack path.
return message.ack();
}
Replace the private synchronized accept() method with this public transactional version:
@Transactional(REQUIRES_NEW)
public void accept(String instanceId, String state, TripPlanStatus result) {
if (result == null || !instanceId.equals(result.instanceId()) || !state.equals(result.status())) return;
TripPlanEntity entity = TripPlanEntity.find("instanceId", instanceId)
.withLock(LockModeType.PESSIMISTIC_WRITE).firstResult();
if (entity == null || !entity.requestId.equals(result.requestId())
|| !entity.request.equals(result.request())) return;
boolean planning = "planning".equals(entity.status);
if (planning && !state.equals("awaiting_approval") && !state.equals("failed")) return;
if (!planning && (!"decision_submitted".equals(entity.status) || state.equals("awaiting_approval"))) return;
if (state.equals("awaiting_approval") && result.plan() == null) return;
if (state.equals("confirmed") && result.confirmation() == null) return;
// Finalization cannot replace or discard the plan the customer reviewed.
entity.status = state;
if (planning) entity.plan = result.plan();
entity.confirmation = result.confirmation();
entity.error = result.error();
entity.message = result.message();
}
@Blocking moves the database work off the messaging event loop. accept() locks the trip row so concurrent updates cannot overwrite each other, and @Transactional(REQUIRES_NEW) commits its changes before message.ack() runs. Quarkus applies this transaction even though the call comes from the same bean. Malformed JSON is ignored, while a database failure goes to the messaging failure handler and leaves the event unacknowledged.
accept() checks that an outcome belongs to the right trip and can follow its current state. A completed trip keeps its outcome when an old approval event arrives, and a final outcome adds its confirmation or error next to the plan the customer reviewed.
Keeping the decision and failure outcome
The workflow checks a decision against the one accepted by the REST resource before resuming. Saving that accepted decision alongside the trip lets this check continue to work after the in-memory maps are gone.
Replace submitDecision() and matchesDecision() with these database-backed versions:
@Transactional(REQUIRES_NEW)
public TripPlanStatus submitDecision(TripApproval approval) {
TripPlanEntity entity = TripPlanEntity.find("instanceId", approval.instanceId())
.withLock(LockModeType.PESSIMISTIC_WRITE).firstResult();
if (entity == null) throw error(404, "unknown_trip", "The requested trip was not found.");
if (!"awaiting_approval".equals(entity.status)) {
throw error(409, "decision_not_pending", "This trip is not awaiting a decision.");
}
entity.status = "decision_submitted";
entity.acceptedDecision = approval;
entity.confirmation = null;
entity.error = null;
entity.message = null;
return toStatus(entity);
}
@Transactional(REQUIRES_NEW)
public boolean matchesDecision(String instanceId, TripApproval approval) {
TripPlanEntity entity = TripPlanEntity.find("instanceId", instanceId).firstResult();
return entity != null && "decision_submitted".equals(entity.status)
&& approval != null && approval.equals(entity.acceptedDecision);
}
Locking the row while accepting a decision makes concurrent submissions take turns. The first moves it to decision_submitted, and a later submission receives HTTP 409 because the trip is no longer awaiting a decision. The confirmed, rejected, or failed outcome is recorded when its event arrives.
Replace submissionFailed() and onWorkflowFailed() with the following versions, removing their synchronized modifiers and the old map updates and notifyAll() call:
@Transactional(REQUIRES_NEW)
public void submissionFailed(String requestId, Throwable failure) {
TripPlanEntity entity = TripPlanEntity.find("requestId", requestId)
.withLock(LockModeType.PESSIMISTIC_WRITE).firstResult();
if (entity == null || !("planning".equals(entity.status) || "awaiting_approval".equals(entity.status)
|| "decision_submitted".equals(entity.status))) return;
TripPlanStatus failed = toStatus(entity).failed(failure);
entity.status = failed.status();
entity.confirmation = null;
entity.error = failed.error();
entity.message = failed.message();
}
@Override
public void onWorkflowFailed(WorkflowFailedEvent event) {
// Publishing the domain outcome can itself fail. Only a terminal engine failure takes this fallback path.
TripPlanStatus previous = byInstanceId(event.workflowContext().instanceData().id());
if (previous != null) submissionFailed(previous.requestId(), event.cause());
}
If a workflow fails while publishing an outcome, the lifecycle listener saves a safe error in the database while retaining the original request and any reviewed plan. A trip that already has a final outcome keeps it when a late failure arrives.
Reading saved trips in short transactions
The planning HTTP request waits for its result but releases its database connection between reads while the agents run. Each status lookup gets a short transaction, with the polling delay outside it.
Update awaitPlan() as highlighted. Remove synchronized and replace the map read and timedWait() call, without adding a transaction around this method:
public TripPlanStatus awaitPlan(String requestId, Duration timeout) throws InterruptedException {
long deadline = System.nanoTime() + timeout.toNanos();
while (true) {
TripPlanStatus status = byRequestId(requestId);
if (status != null && !"planning".equals(status.status())) return status;
long remaining = deadline - System.nanoTime();
if (remaining <= 0) return null;
// Each read has its own transaction; no connection is held while waiting.
TimeUnit.NANOSECONDS.sleep(Math.min(remaining, TimeUnit.MILLISECONDS.toNanos(50)));
}
}
Replace byRequestId(), byInstanceId(), and latest(), removing their synchronized modifiers, and add toStatus(). Keep the existing error() helper and the separate TripPlanStatus record unchanged:
@Transactional(REQUIRES_NEW)
public TripPlanStatus byRequestId(String requestId) {
return toStatus(TripPlanEntity.find("requestId", requestId).firstResult());
}
@Transactional(REQUIRES_NEW)
public TripPlanStatus byInstanceId(String instanceId) {
return toStatus(TripPlanEntity.find("instanceId", instanceId).firstResult());
}
@Transactional(REQUIRES_NEW)
public TripPlanStatus latest() {
// Registration is serialized, so generated IDs give a stable request order, including terminal trips.
return toStatus(TripPlanEntity.find("order by id desc").firstResult());
}
private TripPlanStatus toStatus(TripPlanEntity entity) {
return entity == null ? null : new TripPlanStatus(entity.requestId, entity.instanceId, entity.request,
entity.status, entity.plan, entity.confirmation, entity.error, entity.message);
}
Each lookup runs in a short transaction, so awaitPlan() holds a connection only while it reads. Two details are easy to miss:
latest()orders by sequence ID, so the latest request stays the most recently created trip, even after an older trip is updated or the application restarts.toStatus()builds the API response from the entity’s typed fields, so the browser receives the saved plan and current outcome.
Complete updated TripPlanStore.java
The complete file is included here for comparison with the focused edits above.
package com.tripplanner.agentic.flow;
import java.time.Duration;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.TimeUnit;
import org.eclipse.microprofile.reactive.messaging.Incoming;
import org.eclipse.microprofile.reactive.messaging.Message;
import org.jboss.logging.Logger;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tripplanner.model.PlanningRequest;
import com.tripplanner.model.TripApproval;
import com.tripplanner.model.TripError;
import com.tripplanner.model.TripPlanEntity;
import com.tripplanner.model.TripPlanStatus;
import com.tripplanner.model.TripRequest;
import io.serverlessworkflow.impl.lifecycle.WorkflowExecutionListener;
import io.serverlessworkflow.impl.lifecycle.WorkflowFailedEvent;
import io.smallrye.reactive.messaging.annotations.Blocking;
import io.smallrye.reactive.messaging.ce.CloudEventMetadata;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.LockModeType;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import static jakarta.transaction.Transactional.TxType.REQUIRES_NEW;
@ApplicationScoped
public class TripPlanStore implements WorkflowExecutionListener {
private static final Logger LOG = Logger.getLogger(TripPlanStore.class);
@Inject
ObjectMapper objectMapper;
@Transactional(REQUIRES_NEW)
public PlanningRequest register(TripRequest request) {
TripPlanEntity entity = new TripPlanEntity();
entity.requestId = UUID.randomUUID().toString();
entity.request = request;
entity.status = "planning";
entity.persist();
return new PlanningRequest(entity.requestId, request);
}
// Bind the persisted request before any model work or result publication.
@Transactional(REQUIRES_NEW)
public TripPlanStatus bind(PlanningRequest input, String instanceId) {
TripPlanEntity entity = TripPlanEntity.find("requestId", input.requestId())
.withLock(LockModeType.PESSIMISTIC_WRITE).firstResult();
if (entity == null || !"planning".equals(entity.status) || entity.instanceId != null
|| !Objects.equals(input.request(), entity.request)) {
throw new IllegalStateException("Unknown or duplicate planning request");
}
entity.instanceId = Objects.requireNonNull(instanceId);
return toStatus(entity);
}
@Incoming("flow-out-consumer")
@Blocking
public CompletionStage<Void> consume(Message<String> message) {
CloudEventMetadata<?> event = message.getMetadata(CloudEventMetadata.class).orElse(null);
if (event == null) return message.ack();
String instanceId = event.<String>getExtension("flowinstanceid").orElse(null);
String state = switch (event.getType()) {
case "com.tripplanner.trip.approval.requested" -> "awaiting_approval";
case "com.tripplanner.booking.finalized" -> "confirmed";
case "com.tripplanner.trip.rejected" -> "rejected";
case "com.tripplanner.trip.failed" -> "failed";
default -> null;
};
if (instanceId == null || state == null) return message.ack();
try {
accept(instanceId, state, objectMapper.readValue(message.getPayload(), TripPlanStatus.class));
} catch (JsonProcessingException e) {
LOG.warn("Ignoring malformed trip outcome event");
}
// accept() commits before acknowledgement. Database failures escape to messaging's nack path.
return message.ack();
}
@Transactional(REQUIRES_NEW)
public void accept(String instanceId, String state, TripPlanStatus result) {
if (result == null || !instanceId.equals(result.instanceId()) || !state.equals(result.status())) return;
TripPlanEntity entity = TripPlanEntity.find("instanceId", instanceId)
.withLock(LockModeType.PESSIMISTIC_WRITE).firstResult();
if (entity == null || !entity.requestId.equals(result.requestId())
|| !entity.request.equals(result.request())) return;
boolean planning = "planning".equals(entity.status);
if (planning && !state.equals("awaiting_approval") && !state.equals("failed")) return;
if (!planning && (!"decision_submitted".equals(entity.status) || state.equals("awaiting_approval"))) return;
if (state.equals("awaiting_approval") && result.plan() == null) return;
if (state.equals("confirmed") && result.confirmation() == null) return;
// Finalization cannot replace or discard the plan the customer reviewed.
entity.status = state;
if (planning) entity.plan = result.plan();
entity.confirmation = result.confirmation();
entity.error = result.error();
entity.message = result.message();
}
@Transactional(REQUIRES_NEW)
public TripPlanStatus submitDecision(TripApproval approval) {
TripPlanEntity entity = TripPlanEntity.find("instanceId", approval.instanceId())
.withLock(LockModeType.PESSIMISTIC_WRITE).firstResult();
if (entity == null) throw error(404, "unknown_trip", "The requested trip was not found.");
if (!"awaiting_approval".equals(entity.status)) {
throw error(409, "decision_not_pending", "This trip is not awaiting a decision.");
}
entity.status = "decision_submitted";
entity.acceptedDecision = approval;
entity.confirmation = null;
entity.error = null;
entity.message = null;
return toStatus(entity);
}
@Transactional(REQUIRES_NEW)
public boolean matchesDecision(String instanceId, TripApproval approval) {
TripPlanEntity entity = TripPlanEntity.find("instanceId", instanceId).firstResult();
return entity != null && "decision_submitted".equals(entity.status)
&& approval != null && approval.equals(entity.acceptedDecision);
}
@Transactional(REQUIRES_NEW)
public void submissionFailed(String requestId, Throwable failure) {
TripPlanEntity entity = TripPlanEntity.find("requestId", requestId)
.withLock(LockModeType.PESSIMISTIC_WRITE).firstResult();
if (entity == null || !("planning".equals(entity.status) || "awaiting_approval".equals(entity.status)
|| "decision_submitted".equals(entity.status))) return;
TripPlanStatus failed = toStatus(entity).failed(failure);
entity.status = failed.status();
entity.confirmation = null;
entity.error = failed.error();
entity.message = failed.message();
}
@Override
public void onWorkflowFailed(WorkflowFailedEvent event) {
// Publishing the domain outcome can itself fail. Only a terminal engine failure takes this fallback path.
TripPlanStatus previous = byInstanceId(event.workflowContext().instanceData().id());
if (previous != null) submissionFailed(previous.requestId(), event.cause());
}
public TripPlanStatus awaitPlan(String requestId, Duration timeout) throws InterruptedException {
long deadline = System.nanoTime() + timeout.toNanos();
while (true) {
TripPlanStatus status = byRequestId(requestId);
if (status != null && !"planning".equals(status.status())) return status;
long remaining = deadline - System.nanoTime();
if (remaining <= 0) return null;
// Each read has its own transaction; no connection is held while waiting.
TimeUnit.NANOSECONDS.sleep(Math.min(remaining, TimeUnit.MILLISECONDS.toNanos(50)));
}
}
@Transactional(REQUIRES_NEW)
public TripPlanStatus byRequestId(String requestId) {
return toStatus(TripPlanEntity.find("requestId", requestId).firstResult());
}
@Transactional(REQUIRES_NEW)
public TripPlanStatus byInstanceId(String instanceId) {
return toStatus(TripPlanEntity.find("instanceId", instanceId).firstResult());
}
@Transactional(REQUIRES_NEW)
public TripPlanStatus latest() {
// Registration is serialized, so generated IDs give a stable request order, including terminal trips.
return toStatus(TripPlanEntity.find("order by id desc").firstResult());
}
private TripPlanStatus toStatus(TripPlanEntity entity) {
return entity == null ? null : new TripPlanStatus(entity.requestId, entity.instanceId, entity.request,
entity.status, entity.plan, entity.confirmation, entity.error, entity.message);
}
private static WebApplicationException error(int status, String code, String message) {
return new WebApplicationException(Response.status(status).entity(new TripError(code, message)).build());
}
}
Does this make database writes and Kafka sends atomic?
The database transactions cover only the store operations, while model calls, event publication, and polling delays run outside them. A process crash between saving a request or decision and sending its event can still leave a record with no corresponding event, and closing that gap would take a delivery design such as a transactional outbox. The restart exercise checks recovery of a saved approval wait.
Verifying workflow recovery after a restart
We’re ready to try the customer journey that prompted this change, using one trip and leaving it awaiting approval while we restart the application. The agents generate the plan before shutdown, and afterward the application restores what it saved and continues with the customer’s decision.
sequenceDiagram
actor Customer
participant App as Application
participant DB as PostgreSQL
Customer->>App: Request a trip
App->>App: Agents generate the plan
App->>DB: Save trip details and<br/>workflow progress
App-->>Customer: Show plan for approval
Note over App,DB: Application restarts<br/>Database remains
App->>DB: Load workflow progress
DB-->>App: Waiting for approval
Customer->>App: Refresh results page
App->>DB: Read saved trip details
DB-->>App: Original plan
App-->>Customer: Show the same trip
Customer->>App: Approve trip
App->>App: Simulate booking
App->>DB: Save booking confirmation
App-->>Customer: Show confirmation
Start dev mode from your project directory, in the shell where container reuse is enabled:
Open http://localhost:8080 and generate a trip plan with a future start date. Leave it awaiting approval.
Once the plan is ready, the results page shows the workflow and request identifiers above the approval buttons.
Note both identifiers and the trip details shown in the browser. You will compare them with the restored trip after restarting.
Before restarting
Restart only after the trip reports awaiting_approval, before submitting its decision. Keep the workflow definition and configuration unchanged, and don’t submit trips from other clients until the restart checks are done. Stopping during planning can leave an interrupted planning record that blocks new requests until the disposable database is reset. The latest-trip lookup covers the whole application, so another client’s trip would take this one’s place.
Resuming a persisted workflow
Stop the application with Ctrl-C.
Start it again:
Refresh the browser before submitting a decision. The same pending plan and trip details should appear with the identifiers you noted before the restart. Compare them with the latest-trip response to confirm that the saved request and plan are available through the API too.
Now inspect how the application restored that pending trip. Check the startup log for Restoring workflow instance: and find your trip’s identifier at waitApproval. Check that no new planning call appears in the log. The workflow resumes at its approval wait with the plan generated before the restart.
Open the Quarkus Dev UI at http://localhost:8080/q/dev and select Database view on the Agroal card. Inspect the workflowinstanceentity and trip_plan_status tables. The first holds the saved workflow instance. The second holds the original request and plan with status awaiting_approval. Both should have the workflow identifier shown in the browser.
Click Approve Trip and wait for the page to report confirmed. The restored workflow completes the simulated booking with the same identifiers and plan from before the restart.
Check trip_plan_status again in the Dev UI. Its row should now have status confirmed and a booking confirmation in the JSON confirmation field, while request, plan, and accepteddecision remain available.
Stop and start the application once more, then refresh the browser without generating a new trip. The same confirmed trip, reviewed plan, and simulated booking reference should return. The final outcome survives a restart as well as the approval wait.
Keeping a rejected trip after restart
Click Plan Another Trip, generate a new plan, and note its identifiers. Click Reject Trip, wait for the page to report rejected, then refresh it. The rejected banner and reviewed plan should remain visible.
Stop and start the application again, then refresh the page and inspect /trip/plan/latest. The same request and workflow identifiers should return with status: "rejected", the original request, and the reviewed plan. confirmation stays null, and the database row still holds the accepted rejection, so the trip stays rejected and its events end with com.tripplanner.trip.rejected.
The pending trip did not come back
Check that container reuse was enabled before the first run and that the same PostgreSQL container is still available. Confirm that dev mode uses %dev.quarkus.hibernate-orm.schema-management.strategy=update. The pending workflow checkpoint and trip row should still exist after the restart. If the rows remain but the restore log is missing, check the Flow debug log for startup errors.
Trying restoration without a full shutdown
Press s in the dev mode terminal to force a runtime restart without stopping the process. This keeps the Dev Services container running and lets you try workflow restoration without container reuse. The full shutdown-and-restart check above is the one that also shows container reuse working.
Removing the reused PostgreSQL container
Because we enabled reuse, the database container remains available after the application stops. When you no longer need the saved trips, stop the application and identify the workshop’s PostgreSQL container before removing it with the commands below. Removing it also deletes the data used in this exercise.
Use the equivalent commands if you run Podman:
What’s next?
The customer can now come back to a pending trip even after the application restarts. The planner still only knows what the language model remembers about each destination, though. In Step 06, we’ll connect it to an MCP server with weather forecasts and points of interest, so nobody gets sent on a sunny beach week in the middle of a thunderstorm.


