Online edition for BLOGE
0.9.8-RC1· facts verified 2026-09-15 · 中文
Chapter 13 — Durable Execution
Promise: By the end of this chapter you will understand how BLOGE persists execution state so that a graph can survive process restarts, restore its accepted checkpoints, and continue on another eligible worker when the graph definition and durable stores can be reconstructed.
Learning Goals
- Explain what "durable execution" means in BLOGE — checkpoint-based persistence of node outputs, suspend contexts, loop snapshots, and execution identity so a graph can resume after a crash.
- Identify the runtime stores that make durability possible:
ExecutionStore,ExecutionCheckpointStore,WaitStore, andWorkItemStore. - Wire in-memory durable stores into a
GraphEngineand run a suspend → signal → resume flow across two engine instances. - Describe how cold recovery works: execution identity → graph registry lookup → checkpoint reload → continue.
- Recognise the checkpoint types (
NODE_OUTPUT,LOOP_SNAPSHOT,FOREACH_PROGRESS,SUSPEND_CONTEXT,PARTIAL_RESULT) and when each is written.
Prerequisites
- Chapter 6 — Resilience by Design — retry and timeout fundamentals.
- Chapter 12 — Waiting for the World — suspend, signal, and event correlation.
- Familiarity with
SuspendableOperatorandOperatorResult.suspend().
Source Examples
| File | What it shows |
|---|---|
RuntimeGraphEngineDurabilityTest.java | Cross-process signal using runtime stores; two engine instances share state |
RuntimeGraphRegistryRecoveryTest.java | Cold recovery from a published graph definition; hash-mismatch rejection; automatic expired-claim recovery |
PaymentWaitExample.java | End-to-end suspend → publishEvent → resume lifecycle with runtime stores |
payment-wait.bloge | DSL await block with event correlation, timeout, and branching |
RuntimeLoopOperatorDurabilityTest.java | Loop snapshot checkpoint and resume-from-iteration |
Why This Matters
Everything you've learned so far — nodes, dependencies, branches, resilience, waiting — assumes the JVM stays alive. In production that assumption fails:
- A process crashes mid-execution. Without durability, every in-flight node output is lost and the workflow must restart from scratch.
- A suspend can last minutes, hours, or days (waiting for a payment callback, a manager approval). Keeping a thread blocked that long wastes resources and is destroyed by any deploy.
- Scaling out means multiple worker processes. A graph that suspended on Worker A must be resumable on Worker B without Worker A ever coming back.
Durable execution solves all three. The engine writes checkpoints to a persistent store as nodes complete, and writes wait records when a node suspends. On resume — whether triggered by a signal, a timer, or crash recovery — any engine instance can reload those checkpoints and continue from the exact frontier where execution paused.
Key insight: Durability in BLOGE is not "event sourcing" or "replay". The engine skips nodes whose checkpoints it accepts and schedules the remaining frontier. An external effect that happened before its checkpoint was committed can still be uncertain; idempotency and reconciliation remain application responsibilities.
Mental Model
Think of durable execution as a bookmark in a recipe book:
When the payment event arrives, any engine instance can:
- Load the
ExecutionInstancefrom theExecutionStore. - Reload the
NODE_OUTPUTcheckpoints for the three completed nodes. - Resolve the
WAIT_SIGNALwait record. - Resume from
awaitPayment→ branch →fulfillOrder.
The four pillars of this model:
| Pillar | Store | What it records |
|---|---|---|
| Identity | ExecutionStore | Execution lifecycle, status, lease, recovery attempts |
| Checkpoints | ExecutionCheckpointStore | Node outputs, loop snapshots, suspend contexts, foreach progress |
| Waits | WaitStore | Why the execution is paused (signal, timer, task, event) |
| Work items | WorkItemStore | Pending actions: timer-due, event-matched, task-resume |
The execution lifecycle as a state diagram:
The bloge-runtime-spi Module
Before any code, name the seam. The durable execution layer is split into two modules:
bloge-runtime-spi— interfaces only.ExecutionStore,ExecutionCheckpointStore,WaitStore,TimerService,GraphRegistryStore,LeaseStore,AuditJournalStore,TaskInboxStore, plus the typed events those stores emit.bloge-durable— engine plumbing that consumes those interfaces: the recovery loop, hash check, checkpoint codec, and the in-memory implementations used in tests.
Database-backed implementations live in their own modules
(bloge-durable-mybatis for MyBatis/JDBC). The split means: if you only
need to plug in a custom backing store, depend on bloge-runtime-spi
alone — you do not have to bring in the engine artifact.
<dependency>
<groupId>com.leanowtech</groupId>
<artifactId>bloge-runtime-spi</artifactId>
</dependency>
First Working Example
This example is extracted from
RuntimeGraphEngineDurabilityTest.
It demonstrates a graph that suspends on one engine instance and completes
on another — exactly the "cross-process signal" pattern you need in
production.
Step 1 — Create the stores and the graph
// Shared durable stores (in production these would be database-backed)
var executionStore = new InMemoryExecutionStore();
var checkpointStore = new InMemoryExecutionCheckpointStore();
var waitStore = new InMemoryWaitStore();
var timerService = new InMemoryTimerService();
// A suspendable operator that parks execution
SuspendableOperator<Void, String> waitOp = (in, ctx) ->
OperatorResult.suspend("ck", null, Duration.ofMinutes(10));
Operator<Object, String> completeOp = (in, ctx) -> "C";
var graphBuilder = new GraphBuilder("runtime-cross-process");
var graph = graphBuilder
.suspendNode("w", waitOp)
.node("c", completeOp).dependsOn("w")
.build();
CountDownLatch suspended = new CountDownLatch(1);
AtomicReference<String> executionId = new AtomicReference<>();
ExecutionListener listener = new ExecutionListener() {
@Override
public void onGraphStart(String graphName, GraphContext ctx) {
executionId.set((String) ctx.get(ReservedKeys.EXECUTION_ID));
}
@Override
public void onNodeSuspended(String graphName, String nodeId, String suspendKey) {
if ("w".equals(nodeId)) {
suspended.countDown();
}
}
};
Step 2 — Execute until suspension on Engine 1
var engine1 = GraphEngine.builder()
.registry(new DefaultOperatorRegistry())
.executionStore(executionStore)
.executionCheckpointStore(checkpointStore)
.waitStore(waitStore)
.timerService(timerService)
.listeners(List.of(listener))
.build();
// The source test runs executeWithOperators() on a background thread so it can
// observe suspension and then resume from a second engine instance.
Thread runThread = Thread.ofVirtual().start(() -> {
try {
engine1.executeWithOperators(graph, null, graphBuilder.operators());
} catch (Exception ignored) {
}
});
// The execution is now SUSPENDED in the store
assertTrue(suspended.await(5, TimeUnit.SECONDS));
assertNotNull(executionId.get());
assertEquals(ExecutionStatus.SUSPENDED,
executionStore.get(executionId.get()).orElseThrow().status());
Step 3 — Resume on a different Engine 2
var engine2 = GraphEngine.builder()
.registry(new DefaultOperatorRegistry())
.executionStore(executionStore) // same stores
.executionCheckpointStore(checkpointStore)
.waitStore(waitStore)
.timerService(timerService)
.build();
// Signal resumes the suspended node and continues to "c"
engine2.signal(graph, executionId.get(), "w", "resume-data");
// Execution completes
assertEquals(ExecutionStatus.COMPLETED,
executionStore.get(executionId.get()).orElseThrow().status());
assertEquals("C",
CheckpointCodec.DEFAULT.deserialize(
checkpointStore.load(executionId.get(), CheckpointType.NODE_OUTPUT, "c")
.orElseThrow().payload()));
runThread.join(1_000);
Engine 1 and Engine 2 are completely independent objects. They share nothing except the durable stores. This is the fundamental production pattern: one process suspends, another resumes.
Cold Recovery: What Survives Process Death
Warm resume is easy to overestimate because the original engine still holds
objects in memory. A real durable claim needs Engine 1 to disappear, Engine 2
to load the same executionId, and completed work to remain completed.
Follow loan-42, not an engine object
| Moment | Durable fact | Runtime consequence |
|---|---|---|
| Engine 1 checkpoints | fetchApplication and checkCredit completed | Their outputs can be restored |
| Process stops | Heap objects disappear | In-memory references prove nothing |
Engine 2 claims loan-42 | Graph hash and checkpoint match | Only unfinished nodes become schedulable |
| Recovery completes | New checkpoint records later work | Same logical execution continues |
The identity belongs to the execution record, not to either engine instance. The graph definition is loaded again and checked against retained state before unfinished work is dispatched.
The effect can still be UNKNOWN
Suppose Engine 1 called approveLoan, the lender committed the approval, and
the process died before the completion checkpoint. Engine 2 sees an unfinished
node. The checkpoint cannot tell whether the external effect happened.
Recovery therefore needs the Chapter 6 contract: stable idempotency key, reconciliation read, and an explicit policy for retry or manual repair. Durable execution prevents completed engine work from being scheduled again when a completion was retained; it does not manufacture exactly-once effects across an uncoordinated external system.
Break It Apart
Checkpoint types
The engine writes different checkpoint types at different lifecycle moments.
These are defined in
CheckpointType:
| Type | When written | Purpose |
|---|---|---|
NODE_OUTPUT | Each node completes | Serialized output — skipped on resume |
SUSPEND_CONTEXT | An operator returns OperatorResult.suspend() | Suspend key, partial output, graph context snapshot |
PARTIAL_RESULT | Alongside SUSPEND_CONTEXT | Intermediate output visible while suspended |
LOOP_SNAPSHOT | After each loop iteration | completedIterations + carryStateJson so loops resume mid-way |
FOREACH_PROGRESS | After each sequential foreach item | Per-item progress so foreach can skip finished items |
EVENT_CORRELATION | When an await registers a matcher | Correlation key so events route to the right execution |
SESSION_PHASE_SNAPSHOT / SESSION_ROUND_SNAPSHOT | Session phase/round transitions | Session-specific state (Chapter 14 — Multi-Turn Sessions) |
Execution lifecycle states
ExecutionStatus
tracks where an execution is in its lifecycle:
RUNNING→ nodes are actively executingSUSPENDED→ at least one node returnedOperatorResult.suspend()COMPLETED→ all terminal nodes finished successfullyFAILED→ a node failed after all resilience attemptsFAILED_RECOVERY→ automatic crash recovery exceededmaxRecoveryAttemptsCANCELLED/TERMINATED→ explicitly stopped
Wait types
WaitType
records why an execution is suspended:
WAIT_SIGNAL— waiting forengine.signal()with a matching keyWAIT_TIMER— a timeout-based auto-resumeWAIT_EVENT— waiting forengine.publishEvent()with a correlation matchWAIT_TASK— waiting for a user-task completionWAIT_PHASE_TIMEOUT— a session phase-level timeout firedWAIT_ROUND_TIMEOUT— a session round-level timeout firedWAIT_RETRY_BACKOFF— a delayed retry scheduled as a durable work item
If sessions are new to you, Chapter 14 — Multi-Turn Sessions
unpacks how phase/round timeouts and session snapshots fit on top of the same
durable machinery. State-machine durability takes a different path:
StateMachineCheckpoint bridges through the generic execution-checkpoint SPI
instead of introducing a new core checkpoint type, which you will meet in
Chapter 15 — State Machines.
The DurableManager bridge
DurableManager
is the internal bridge that the GraphEngine uses to coordinate writes across
all stores. You don't call it directly — the engine calls it when:
- A graph starts →
initExecution() - A node completes →
saveNodeCompletion() - A node suspends → writes suspend context plus the corresponding wait/timer records
- A loop iteration finishes →
saveLoopSnapshot() - A resume completes → cleanup suspend context + wait records
Common Trap
❌ Assuming checkpoint data keeps its original Java type
When you suspend and later resume, checkpoint payloads are serialized to JSON
and deserialized back. If your operator output was a PaymentEvent record,
after deserialization it will be a Map<String, Object> unless you use a
typed CheckpointCodec.
// FRAGILE — will ClassCastException after resume
PaymentEvent event = results.get("awaitPayment", PaymentEvent.class);
// SAFE — handle both fresh-run and checkpoint-restored shapes
Object raw = results.getRaw("awaitPayment");
String txnId;
if (raw instanceof PaymentEvent pe) {
txnId = pe.transactionId();
} else if (raw instanceof Map<?,?> m) {
txnId = (String) m.get("transactionId");
}
This pattern appears throughout the
PaymentWaitExample.
Fix: Either use getRaw() with pattern matching, or wire a
JacksonCheckpointCodec
with typed deserialization into GraphEngine.builder().checkpointCodec(...).
What Goes Wrong
Checkpoint deserialization failure after schema change
You deploy version 2 of an operator that changes the output type from
record OrderOutput(String orderId, int total) to
record OrderOutput(String orderId, BigDecimal total, String currency).
An in-flight execution was checkpointed with the v1 schema. When recovery loads the checkpoint:
Recovery error: cannot deserialize the checkpoint for node 'createOrder'
because required field 'currency' is missing from the stored payload.
Fix: Make schema changes backward-compatible (add optional fields, don't
remove required ones). For breaking changes, drain in-flight executions before
deploying, or implement a CheckpointCodec that handles version migration.
Guided Rewrite
Open
RuntimeGraphRegistryRecoveryTest.
The test publishesDslGraphAndRecoversColdSignalWithoutCallerGraph demonstrates
cold recovery — resuming without the caller passing a Graph object:
// Engine 1: execute with a DSL graph — the engine publishes the
// GraphDefinition to graphRegistryStore automatically
GraphEngine engine1 = GraphEngine.builder()
.registry(registry)
.executionStore(executionStore)
.executionCheckpointStore(checkpointStore)
.waitStore(waitStore)
.graphRegistryStore(graphRegistryStore) // ← enables cold recovery
.addGraphDefinitionCodec(codec) // ← how to decode DSL
.timerService(timerService)
.build();
engine1.execute(graph, new GraphContext(Map.of("sessionId", "SESSION-001")));
// Graph suspends at "wait" node
// Engine 2: signal WITHOUT passing the graph object
GraphEngine engine2 = GraphEngine.builder()
.registry(recoveryRegistry)
.executionStore(executionStore)
.executionCheckpointStore(checkpointStore)
.waitStore(waitStore)
.graphRegistryStore(graphRegistryStore)
.addGraphDefinitionCodec(codec)
.timerService(timerService)
.build();
// The engine loads the graph from graphRegistryStore using the
// execution's graphVersion + graphHash binding
engine2.signal(executionId, "wait", Map.of("message", "resume"));
Questions to consider:
-
What happens if the graph definition changes between suspend and resume? The engine compares the
graphHashstored inExecutionIdentitywith the hash of the publishedGraphDefinition. If they don't match, recovery fails with a"hash mismatch"error. This is tested in therejectsColdRecoveryWhenPublishedGraphHashChangestest case. -
What about automatic crash recovery? When
RecoveryConfigis enabled and an execution's lease expires (the worker crashed), another engine instance can automatically claim the expired execution and re-execute it. TherecoveryAttemptscounter is incremented each time. If the budget is exhausted, the status transitions toFAILED_RECOVERY. -
Where are graph definitions stored? In the
GraphRegistryStore, which persistsgraphName + graphVersion + graphHash + source. In production this is backed by thebd_graph_definitiondatabase table.
Graph Definition Source
When the engine publishes a graph to the GraphRegistryStore, it needs to know
where the definition came from — a DSL file on disk, a Java builder call, or
a registry import. The GraphDefinitionSource interface
(com.leanowtech.bloge.core.runtime.registry.GraphDefinitionSource) captures
that provenance:
public interface GraphDefinitionSource {
String sourceId(); // unique identifier for this source
String sourceType(); // e.g. "dsl-file", "java-builder", "registry"
Optional<String> content(); // raw source content if available
}
Attach a source when building a graph for registry publication:
var source = new FileBasedGraphDefinitionSource(
"file:///graphs/order-process.bloge", dslContent);
Graph graph = Graph.builder("orderProcess")
.definitionSource(source)
// ... nodes and edges ...
.build();
engine.publishGraphDefinition(graph);
The three fields serve different recovery needs:
| Field | Purpose |
|---|---|
sourceId() | Uniquely identifies the origin — a file path, a classpath resource, or a registry URI |
sourceType() | Tells the registry how the definition was produced ("dsl-file", "java-builder", "registry") |
content() | Optionally stores the raw DSL text so the definition can be reconstructed without the original file |
Without a GraphDefinitionSource, the registry stores the compiled graph
structure but loses the trail back to the original artifact. For production
systems that need audit trails or multi-cluster replication, always attach a
source.
Checkpoint Codec SPI
By default the engine serializes checkpoint payloads as JSON. For many workflows this is fine, but production systems sometimes need:
- Custom compression to reduce storage costs for high-volume workflows
- Encryption for checkpoints that contain sensitive data (PII, financial records)
- Compatibility layers that handle schema migration across deployments
The CheckpointCodec SPI
(com.leanowtech.bloge.core.checkpoint.CheckpointCodec) lets you replace the
default serialization:
public interface CheckpointCodec {
byte[] encode(ExecutionCheckpoint checkpoint);
ExecutionCheckpoint decode(byte[] data);
}
Wire it into the engine builder:
GraphEngine engine = GraphEngine.builder()
.registry(registry)
.checkpointCodec(new JsonCheckpointCodec()) // custom serialization
.build();
A typed codec also eliminates the Map<String, Object> problem described in
the Common Trap section above — if your codec knows the target types, restored
checkpoints come back as proper Java records instead of raw maps.
Operator Fingerprint Mismatch Policy
Cold recovery still rejects a graph-definition hash mismatch. A separate
question appears when a completed node has a saved Operator fingerprint but
the registered Operator implementation now has another fingerprint.
VersionMismatchPolicy controls only that checkpoint decision:
| Policy | Behaviour |
|---|---|
WARN | Restore the checkpoint and log the Operator mismatch; this is the default. |
RERUN | Skip that checkpoint so the node is scheduled again. |
FAIL | Abort resume with OperatorVersionMismatchException. |
Wire an explicit choice into the engine builder:
GraphEngine engine = GraphEngine.builder()
.registry(registry)
.versionMismatchPolicy(VersionMismatchPolicy.FAIL)
.build();
Choose by effect semantics, not environment labels. RERUN is safe only when
the node can repeat without duplicating an irreversible effect. WARN accepts
old output under new code and therefore needs an explicit compatibility
decision. FAIL stops automatic recovery and leaves migration or operator
intervention outside the engine.
Production warning — schema migration is a separate version axis
Treat the dialect-specific Flyway migrations packaged in
bloge-durable-mybatisas authority./actuator/bloge/schemareportsUP_TO_DATE,PENDING, orFAILED; apply required schema migrations before deploying binaries that depend on them. In RC1, V25 only migrates legacy session enum values. Recovery leases were introduced by V17, the audit journal table by V9, and lease fencing by V26. Appendix H keeps the operational migration checklist outside this narrative chapter.
Brain Check
-
What are the four runtime stores that enable durable execution? (ExecutionStore, ExecutionCheckpointStore, WaitStore, WorkItemStore.)
-
What happens to already-completed nodes when an engine resumes a suspended execution? (They are skipped. The engine loads their NODE_OUTPUT checkpoints and populates the NodeResults without re-executing the operators.)
-
Why does the engine reject a cold recovery when the graph hash changes? (Because the graph structure may have changed — new nodes, removed edges, different branching — so the checkpointed node outputs may not correspond to the current graph. Resuming would produce undefined behaviour.)
-
What
ExecutionStatusdoes an execution transition to when automatic recovery exceedsmaxRecoveryAttempts? (FAILED_RECOVERY— a terminal status.) -
If a
SuspendableOperatorreturnsOperatorResult.suspend("key", partial, Duration.ofMinutes(5)), how many wait records are created? (Two: oneWAIT_SIGNALfor the manual signal resume path, and oneWAIT_TIMERfor the 5-minute timeout auto-resume path.) -
Design question: Your durable workflow processes insurance claims that take 2–4 weeks. During that time, you will deploy multiple code updates. What is your strategy for ensuring in-flight executions survive deployments? (Keep operator output schemas backward-compatible. Use
GraphRegistryStoreto version graphs so recovery can find the correct graph definition. Design compensation operators to be idempotent so that a recovered execution re-running a node is safe. Test recovery in staging with long-running executions before shipping.)
Lab
Goal: Build a two-phase durable payment flow and simulate a crash between phases.
-
Create a graph with three nodes:
createOrder— a normal operator that returns an order ID.awaitPayment— aSuspendableOperatorthat suspends withOperatorResult.suspend("payment:" + orderId, null, Duration.ofSeconds(30)).fulfillOrder— depends onawaitPayment, returns a shipment ID.
-
Wire runtime stores (use the in-memory implementations):
var executionStore = new InMemoryExecutionStore();var checkpointStore = new InMemoryExecutionCheckpointStore();var waitStore = new InMemoryWaitStore(); -
Execute the graph on
engine1. Verify that:- The execution status is
SUSPENDED. - A
NODE_OUTPUTcheckpoint exists forcreateOrder. - A
SUSPEND_CONTEXTcheckpoint exists forawaitPayment. - Wait records contain both
WAIT_SIGNALandWAIT_TIMER.
- The execution status is
-
Create
engine2with the same stores but a freshDefaultOperatorRegistry. Signal the suspended node:engine2.signal(graph, executionId, "awaitPayment", paymentData); -
Assert that:
- The execution completes.
fulfillOrderhas aNODE_OUTPUTcheckpoint.- All wait records have been cleaned up.
Stretch: Enable GraphRegistryStore and try calling engine2.signal()
without passing the graph object — use only the execution ID.
Experiment acceptance card
- Expected and observed: A new process takes over using identity, definition, and checkpoint.
- Failure and recovery: Change the definition version; recover with a match or explicit migration.
- Proof boundary: Proves engine cold recovery, not known external-effect state.
- Exercise contract: Interrupted checkpoint; change one version; deliver accepted/rejected receipts; stop when completed nodes do not rerun and UNKNOWN stays separate.
Recap
- Durable execution means persisting node outputs, suspend contexts, wait records, and execution identity so a graph survives crashes and can be resumed by any engine instance.
- The four runtime stores —
ExecutionStore,ExecutionCheckpointStore,WaitStore, andWorkItemStore— are the foundation. Use in-memory implementations for development; MyBatis-backed implementations for production. - On resume, the engine loads checkpoints and skips completed nodes. It does not replay already-finished work.
- Cold recovery uses
GraphRegistryStoreto reload the graph definition from itsgraphVersion + graphHashbinding — no caller-suppliedGraphneeded. - The engine rejects recovery when the graph hash changes, preventing checkpoint/graph mismatches.
- Automatic crash recovery uses lease expiry and
RecoveryConfigto let healthy workers claim expired executions, with arecoveryAttemptsbudget. - Checkpoint payloads are JSON-serialized. Use
getRaw()with pattern matching or a customCheckpointCodecto handle type differences between fresh runs and restored executions.
Next Step
In Chapter 14 — Multi-Turn Sessions you will move from durable one-shot graphs to longer-lived interactions. The same checkpointing machinery you learned here becomes the substrate for phase/round sessions, and then for explicit state machines in Chapter 15.
Reference Links
bloge-durableREADME — module overview, store table, quick start, schema migrationsbloge-durable-codecREADME —JacksonCheckpointCodecand custom serializationCheckpointCodecSPI — the codec interfaceExecutionCheckpointStore— checkpoint store SPIExecutionCheckpoint— checkpoint record with identity, type, payload, and operator fingerprintCheckpointType—NODE_OUTPUT,LOOP_SNAPSHOT,FOREACH_PROGRESS,SUSPEND_CONTEXT, etc.ExecutionIdentity— tenant, namespace, business key, graph binding, routingExecutionInstance— lifecycle record with lease, version, recovery attemptsExecutionStatus—RUNNING,SUSPENDED,COMPLETED,FAILED,FAILED_RECOVERYWaitType—WAIT_SIGNAL,WAIT_TIMER,WAIT_EVENT,WAIT_TASK,WAIT_PHASE_TIMEOUT,WAIT_ROUND_TIMEOUT,WAIT_RETRY_BACKOFFOperatorResult—CompletedvsSuspendedsealed result typeSuspendableOperator— operator contract for nodes that may suspendDurableManager— internal bridge between engine and durable storesDurableStoreFactory— production store wiring with MyBatis + Flyway- Core Architecture — engine internals and package structure
Coding Agent: Open the versioned task guide.