All posts

What Is Process Orchestration? A Definition You Can Test, on Camunda 8 and Temporal

November 4, 2026Michał Woliński19 min read
What Is Process Orchestration? A Definition You Can Test, on Camunda 8 and Temporal

Process orchestration is coordinating the steps of a long-running business process from one place: a component that decides what happens next, remembers where every running case is, and survives the crash of anything it calls. The steps may be services, people, external systems or plain waiting. The orchestrator does none of that work itself. Its job is the order of the work and the memory of it.

That is the definition I will defend here, and I will do it by experiment, because many explanations of the term come from companies selling it. I built one order process four ways: as a plain method, as events between services on RabbitMQ, on Camunda 8 and on Temporal. Then I killed things, with no chance to clean up, and watched what each one remembered.

Three results surprised me enough to mention now. Choreography survives a worker crash just as well as an engine does, so resilience is not the reason to orchestrate. Engines do not give you exactly-once: the same crash charged a card twice on all three designs that retry. And Camunda's default job polling cost more than ten times the latency of its streaming mode, on one flag.

Everything is in the companion repository. Every block of source below is a real file there, and its first line says which one. Claims about behaviour are backed by its 33 tests, which run against real engines in Docker. Where I rely on documentation alone, the text says so.

A definition you can test

Camunda, which sells this, defines it as "the coordination of the tasks that make up a process, both automated and manual", and separates it from task automation, which handles individual tasks without coordinating a larger process. That is a fair start, but it does not help you decide whether a given system qualifies. Here is a version that does. Something orchestrates a process if it has all four of these:

  • The process is written down in one place. You can read the order of the steps without reading every service.
  • The progress outlives the code. Kill any worker, or the engine itself, and each case continues from its step.
  • Waiting is cheap. A case can wait three days for a person or a parcel with nothing of yours required to stay running.
  • You can ask where a case is, and when one is stuck, the answer says so.
A process orchestrator at the top, labelled what happens next and where each order is now, with arrows to five boxes: payment service, warehouse system, a person reviewing, a carrier webhook and a timer three days later. Each box does one task. Only the top box knows the process.
Figure 1. The orchestrator owns the order and the memory. Everything else does one task.

What it is not

The word is overloaded.

the other thingwhat it coordinateshow it differs
Container orchestration (Kubernetes)Where programs runIt keeps processes alive. It knows nothing about order A-17.
Data pipeline orchestration (Airflow)Batch jobs over dataUsually runs on a schedule over a dataset, not per business case waiting on a person.
Task automation, RPAOne taskIt does the work. Something else has to decide when.
Agent orchestrationLLM calls and toolsA model picks the next step at run time. A process has its steps written down, and I covered how to choose between them separately.

"Workflow orchestration" and "workflow automation" are mostly the same idea under other names, and vendors do not use them consistently. Camunda calls process automation "a mix of process orchestration and task automation". I would not build an argument on the difference. Business process management (BPM) is older and wider: a management discipline that may or may not involve an engine.

Analysts have their own label as well. Gartner now groups these products with low-code and integration tools as business orchestration and automation technologies, or BOAT. You will see it on vendor pages. It describes a product market, not an engineering technique.

The vocabulary

termwhat it means
process definitionThe written-down steps: a BPMN file, or workflow code.
process instanceOne running case, such as order A-17. Temporal calls this a workflow execution.
BPMNBusiness Process Model and Notation, a standard for drawing processes that an engine can also run.
job, workerA job is one unit of work the engine hands out. A worker is your code that takes jobs and reports back. Temporal says activity.
incidentCamunda's record that an instance is stuck and needs a human.
correlationMatching an incoming event to the instance waiting for it, usually by a business key.
compensationA step that undoes the business effect of an earlier one, such as a refund.
sagaA long business transaction made of local steps with compensations instead of a rollback.
choreographyThe same process with no coordinator: services react to each other's events.
durable executionRunning ordinary code so that its progress survives a crash, by recording and replaying history.
idempotentSafe to repeat: doing it twice leaves the same result as doing it once.

The process, and the obvious way to write it

A shop charges a card, reserves stock, ships, and waits for delivery. Large orders need a person to look at them. If there is no stock, the money goes back. If the parcel is late, someone chases the carrier.

The order process: start, charge payment, reserve stock, a decision on 1000 or more which goes through a person reviewing when yes, ship order, wait for delivery, end. Reserve stock has a no stock path to refund payment. Wait for delivery has a too late path to chase carrier.
Figure 2. Three of these boxes are ordinary calls. The green ones can take days, and the red ones exist because the world fails.

Here is the happy path as code, the way most of us would start:

// src/main/java/com/wolinski/orchestration/plain/PlainCheckout.java public void place(String order) { payments.chargeBlindly(order); crashHere(); effects.record("reserve:" + order); effects.record("ship:" + order); }

crashHere is the test's switch. I ran this in its own JVM and halted it right after the charge. The log of what really happened in the world reads charge:P-1, and that is all. Nothing knows the order is half done, because all the progress was on a stack that no longer exists. Running it again gives charge, charge, reserve, ship.

You can fix this, and teams do. Add a status column. Add a scheduler that picks up rows stuck in CHARGED. Add a lock so two schedulers do not take the same row, then a retry count, then a timeout, then a dead-letter state, then a screen for support. I wrote about exactly this for an outbox relay, and it took 7,000 words. At the end of that road you own a small process engine, one that handles one process. That is a reasonable outcome for one process. It is a poor one for the tenth.

How an engine works

An engine splits the method above in two. The order of the steps moves into a definition, and the state moves into the engine's durable storage. Your code keeps only the steps themselves. Here is one step in BPMN, with the failure path attached to it:

<!-- src/main/resources/bpmn/order.bpmn --> <bpmn:serviceTask id="reserve" name="Reserve stock"> <bpmn:extensionElements> <zeebe:taskDefinition type="reserve-stock" retries="3" /> </bpmn:extensionElements> </bpmn:serviceTask> <bpmn:boundaryEvent id="no-stock" name="Out of stock" attachedToRef="reserve"> <bpmn:errorEventDefinition errorRef="out-of-stock" /> </bpmn:boundaryEvent>

The standard part says "a service task, with an error path". The zeebe: part is Camunda's extension that names the job, so a file like this is not portable between engines as it stands. And here is the worker, which pulls jobs of that type and knows nothing about what comes before or after:

// src/main/java/com/wolinski/orchestration/camunda/OrderWorkers.java worker("reserve-stock", (jobs, job) -> { String order = orderOf(job.getVariablesAsMap()); if (Boolean.TRUE.equals(job.getVariablesAsMap().get("outOfStock"))) { jobs.newThrowErrorCommand(job).errorCode("OUT_OF_STOCK").send().join(); return; } effects.record("reserve:" + order); jobs.newCompleteCommand(job).send().join(); }),

The loop is the same on both engines here. The engine reaches a step and creates a job. A worker takes it, does the work and reports back. The engine records that, works out the next step, and repeats. Between those moments nothing of yours has to be running.

What you get, tested

All of this ran on Camunda 8.10.8, with Temporal 1.40.0 giving the same results wherever a row names both.

scenariowhat happened
No worker runningThe instance waits at its step. Start a worker later and it carries on.
Worker killed while the order waits (Temporal)A new worker carries on, and finished steps do not repeat.
Engine killed mid-process (both)After a restart, the instance is at the same step, with its variables, and charges and shipments do not repeat.
A 15-second timer, with the engine down at the deadline (both)It does not fire while the engine is down, and fires once after the restart.
Out of stock (both)The error path runs the refund. Nothing ships.
Order of 1000 or moreWaits for a user task and ships once it is completed.
Carrier API down, retries used upAn incident naming the step and the error. The instance stays put until someone updates the retries and resolves it, then carries on.
Left, plain code: progress on the stack, killed, progress is gone. One charge happened, nothing resumes, and running it again charges a second time. Right, an engine: progress is in the engine, it gives a job to a worker that is killed, and after a timeout the next worker gets the same job. The order continues, and the charge may repeat.
Figure 3. Same crash, different memory. Note the last line on the right.

The incident row deserves a second look, since it is the fourth property from the definition. The test finds the instance from the order number A-7, through a search on its variables, and gets back ship with the error text carrier API is down. That is the answer support has wanted all along. On Temporal the same lookup is simply the workflow ID, if you set it to the order number. Two caveats. An incident sits there until someone looks, so it needs an alert like anything else. And on Camunda those queries read a copy that is exported from the engine's log, so my tests poll for the answer rather than assuming it is there at once.

What you do not get

This section is the reason I wrote the article.

Exactly-once

I killed a worker after it charged the card and before it reported back:

// src/test/java/com/wolinski/orchestration/CamundaTest.java // The last two arguments: idempotent = false, crash after charging = true. Process dying = Jvm.start(List.of(), WorkerMain.class, engine.grpc().toString(), engine.rest().toString(), effects.file().toString(), "false", "true"); long instance = startOrder("A-3", Map.of()); assertThat(dying.waitFor()).isEqualTo(137); assertThat(effects.count("charge:A-3")).isEqualTo(1); Process healthy = Jvm.start(List.of(), WorkerMain.class, engine.grpc().toString(), engine.rest().toString(), effects.file().toString(), "false", "false"); try { awaitAt(instance, "wait-delivered"); assertThat(effects.count("charge:A-3")).isEqualTo(2); } finally { healthy.destroyForcibly().waitFor(); }

That test passes, which means the card was charged twice. Temporal did the same, and so did the RabbitMQ version. It is not a defect in any of them.

The engine gives a job to a worker, which charges the card at the payment provider and crashes before reporting complete. After a timeout the engine gives the same job to a second worker, which charges again. The engine only sees that no one reported back.
Figure 4. No engine can close this window, because the charge and the report are two systems.

Both vendors say so. Camunda's guide on workers warns: "Your workers must be idempotent." Temporal's docs on activities recommend "that it be idempotent, so retries can be processed without duplicate side effects". The fix belongs on the side effect: pass a key the provider deduplicates on, or, for your own consumers, keep an inbox.

// src/main/java/com/wolinski/orchestration/camunda/OrderWorkers.java payments.charge(order, "charge-" + order);

With that line the same crash charges once. The key is the order number on purpose. My first version used the process instance key, which looked tidier and has a hole in it: a duplicate instance gets a different key, and the next section shows how easily you get one.

A worker crash is not even required. If the engine goes down between the charge and the report, the report fails, and the job runs again after the restart: two charges, and no worker crashed. And a job that takes longer than its timeout is handed out again while the first run is still going, which a test catches with nothing having failed. So set the timeout above your slowest realistic call, and remember that a longer timeout also means a longer wait after a real crash.

A safe start

Starting a process is a write to a second system, next to whatever you wrote to your own database. That makes it a dual write, the same problem an outbox exists to solve. While Camunda is down, the start simply fails, as a test confirms. And when you retry a start whose outcome you never learned, the engines behave differently:

how you start itwhile the first runsafter that
Camunda, newCreateInstanceCommandA second instanceA second instance
Camunda, message start with a correlation key and a time to liveHeld backStarts as soon as the first finishes
Camunda, the same with a message IDRefusedRefused until the message is gone, then starts
Temporal, a fixed workflow IDWorkflowExecutionAlreadyStartedStarts, and charges and ships again
Temporal, the same with REJECT_DUPLICATEWorkflowExecutionAlreadyStartedRefused, while the old execution is retained, by its docs

Look at the last column. Four of the five look deduplicated in a quick test, because a quick test retries while the first run is still going. A real retry comes later, after a timeout or a replay. Camunda's documentation describes the buffering, and Temporal's reuse policy has "Allow Duplicate" as its default. Even the strict options have a horizon: with a one-second time to live, the same message ID was accepted again about 25 seconds later in my runs. So choose a retention longer than your longest retry, and keep the business key on the side effects as well. In the first row it is what kept a duplicate instance to one charge, though the order still shipped twice.

Putting that together, the design I would use, and did not build here, is this: write the order and an outbox row in one transaction, and let a relay start the process with a stable ID. The relay may start it twice, the engine refuses the duplicate within its horizon, and the idempotent steps cover the rest.

A real rollback

A refund is not a rollback. For a while the customer was charged, the statement shows two lines, and anything else that saw the charge in between has already acted on it. Chris Richardson's saga pattern lists this among its drawbacks as a "lack of isolation". The engine runs your compensation reliably. It cannot make the earlier step never have happened, and the compensation is itself a job that can run twice.

The order of the steps is your main tool here. My example charges first, because that makes the failure path easy to see. In a real shop I would reserve first, since releasing a reservation is cheap and invisible, while a refund costs fees and some of the customer's trust. Put the steps that are hardest to undo last.

Retries that suit your failure

Having retries is not the same as having good ones, and the two engines have opposite defaults.

Camunda 8, a handler that throwsTemporal, default policy
Attempts3, from the modelUnlimited
Gap between attempts10 ms and 9 ms in the committed runStarts at 1 second, doubling, up to 100 seconds, by its docs
A short carrier outageAn incident within milliseconds, and it waits for a humanRetries through it and heals by itself
A carrier gone for goodAn incident, which is visibleStatus RUNNING indefinitely; the attempt count and last error are in describe

On Camunda you get a pager for every blip unless you report the failure yourself with a backoff, which gave gaps of two seconds and more in the test:

// src/test/java/com/wolinski/orchestration/CamundaTest.java jobs.newFailCommand(job).retries(job.getRetries() - 1).retryBackoff(backoff) .errorMessage("carrier API is down").send().join();

On Temporal you get the opposite risk: nothing ever looks broken. My workflow shortens the first interval to 200 ms so that the test is quick, and the test sees the attempts climb with the status still RUNNING, then carries on once the carrier is back. Alert on the attempt count, or set a limit. Neither default is wrong, but each needs a decision from you.

A safe place for data

Whatever you pass as a variable lives in the engine. A test reads a card number straight back through Camunda's search API. Temporal keeps its inputs in the history it replays. Camunda's guide also advises workers to "read and write as little data as possible" from the process. Pass an order number, and let each worker fetch what it needs from where it is already protected.

Free latency

Each step now crosses a network and a durable log. This is what one three-step instance with empty steps cost, on one local node in Docker on my laptop, measured over 200 runs after 50 warm-up runs:

Median time for one three-step instance on a single local node. Plain method calls under 1 microsecond. Camunda 8 with job streaming 23 milliseconds. Temporal with defaults 200 milliseconds. Camunda 8 with default polling 326 milliseconds.
Figure 5. Median of 200 runs. These compare my setups, and are not a ranking of the products.
setupp50p95
Plain method callsunder 1 µsunder 1 µs
Camunda 8, streamEnabled(true)23 ms37 ms
Temporal, defaults200 ms206 ms
Camunda 8, defaults326 ms339 ms

These are from the run committed as test-results.log. Across my eight runs, streaming ranged from 17 to 24 ms and polling from 324 to 328 ms. The practical finding is the gap between the Camunda rows, which is one flag. Job streaming is off by default and the documentation recommends it. Do not read more than that into these numbers. It is one node, on a development database, and I did not measure throughput or a cluster. Temporal's suspiciously round 200 ms is not a fixed delay: with 0, 1, 3, 6 and 10 empty steps it took 50, 99, 200, 350 and 550 ms, so about 50 ms per step on this development server. I did not chase the cause or try to tune it. The order of magnitude is the point: tens to hundreds of milliseconds for steps that are about to wait days is nothing, and on a request path where a user is waiting it is a real cost.

Orchestration or choreography?

The usual comparison says events are decoupled and orchestrators are a single point of failure. I built the event version too, with three services on RabbitMQ, durable queues, publisher confirms and manual acknowledgements (all explained in my RabbitMQ tutorial), to see what it gets wrong. The saga pattern draws the same line: in choreography "each local transaction publishes domain events that trigger local transactions in other services", and in orchestration "an orchestrator (object) tells the participants what local transactions to execute".

Left, orchestration: an engine that knows the process points to payment, stock and shipping. Right, choreography: payment sends payment.taken to stock, stock sends stock.failed back to payment, and stock sends stock.reserved to shipping. The process is the sum of who listens to what.
Figure 6. The same three services. On the right, no file contains the process.

The honest result is that choreography did not lose on resilience. A payment service killed between charging and acknowledging got its message redelivered, and the order carried on. It charged twice as well, for the same reason as every other design here. The difference shows up in the other three properties from the definition:

choreographyorchestration
Survives a worker crashyesyes
Duplicates after that crashyes, unless idempotentyes, unless idempotent
Where the order of steps livesin the bindings of every servicein one definition
A stuck ordera count on a queuea named step, with an error
Compensationanother listener, in another servicea path in the definition
Waiting three days, or for a personyou build itbuilt in
Couplingservices know eventsthe engine knows every step
When the central piece is downbroker down: nothing movesengine down: nothing moves

The stuck-order row is a test. With the shipping consumer stopped, the only trace of order C-4 in the broker is getMessageCount() == 1 on one queue. Nothing fails and nothing is red. To answer "where is C-4?" you would have to build a read model that listens to every event, which is a first step towards writing the engine after all. Distributed tracing helps with the path an order took, but not with noticing that a step should have happened and did not.

The last row is the one the usual comparison skips. An engine is a critical dependency, and so is a broker. Choreography moves that risk, and does not remove it. Choreography is still the right call when the steps really are independent reactions: when one service announces something and others may care, and nobody needs to know the whole story. That describes a lot of real events, and a process engine there would only add a coordinator with nothing to coordinate. Most large systems I would expect to use both: orchestration inside a business capability, where one team owns the process, and events between capabilities, where nobody should own the whole.

Model or code: Camunda and Temporal

Engines split into two families. On Camunda, as on other BPMN engines, the process is a model. On Temporal the process is ordinary code, and the engine makes it durable:

// src/main/java/com/wolinski/orchestration/temporal/OrderWorkflowImpl.java public String place(String orderId, long deliveryDeadlineSeconds, boolean outOfStock) { activities.charge(orderId); try { activities.reserve(orderId, outOfStock); } catch (ActivityFailure failure) { if (failure.getCause() instanceof ApplicationFailure cause && "OUT_OF_STOCK".equals(cause.getType())) { activities.refund(orderId); return "cancelled"; } throw failure; } activities.ship(orderId); if (Workflow.await(Duration.ofSeconds(deliveryDeadlineSeconds), () -> delivered)) { return "delivered"; } activities.chase(orderId); return "chased"; }

That method can wait for days, across restarts, in an ordinary-looking call. It works by replay: after a crash, the worker runs the method again from the top, and the SDK answers each step from the recorded history instead of executing it. It is a pleasure to write, and it is why finished steps did not repeat in my test.

It is not quite ordinary code, though. Temporal's docs say "A Workflow Definition must be deterministic to support replay", so no system clock, no plain random numbers and no threads of your own, and anything touching the outside world goes in an activity. History also has a limit: by its docs, an execution is terminated when it "exceeds 51,200 Events", so a process that loops for a long time has to restart itself with Continue-As-New, which I did not test.

This table comes from their documentation and my opinion; the tests above cover crashes and versions, not these rows.

Camunda 8Temporal
You writeA BPMN model, plus workersJava, Go, Python and others, plus activities
Who can read the processAlso people who do not codeDevelopers
People in the processUser tasks are built inYou build them with signals
Loops and conditionsWhat the model and its expressions allowThe whole language
Testing a processA process test libraryA test environment, with time skipping
Engine licence for productionPaidMIT server; you operate it, or pay for their cloud

That licence row is worth reading before you get attached. Camunda's licensing page states: "To use the software in production, purchase the Camunda Self-Managed Enterprise Edition." The non-production terms cover development, testing and evaluation, as I read them, which is what this repository does, and the Java client is Apache 2.0. Temporal's server is MIT and its SDK is Apache 2.0. If you want open-source BPMN, Flowable is Apache 2.0, though I did not test it. All of these were as stated on November 4, 2026, and this is not legal advice.

A puzzle: the release that breaks nothing new

The business wants a fraud check before the charge. You add one line to the Temporal workflow, and deploy.

// src/main/java/com/wolinski/orchestration/temporal/ReorderedWorkflowImpl.java public String place(String orderId, long deliveryDeadlineSeconds, boolean outOfStock) { activities.fraudCheck(orderId); activities.charge(orderId); try { activities.reserve(orderId, outOfStock); } catch (ActivityFailure failure) { if (failure.getCause() instanceof ApplicationFailure cause && "OUT_OF_STOCK".equals(cause.getType())) { activities.refund(orderId); return "cancelled"; } throw failure; } activities.ship(orderId); if (Workflow.await(Duration.ofSeconds(deliveryDeadlineSeconds), () -> delivered)) { return "delivered"; } activities.chase(orderId); return "chased"; }

The build is green. New orders run the fraud check and ship. No order is marked as failed, and describe() on each of them says RUNNING. A few days later, support asks why orders from last week never closed, although the carrier has confirmed delivery. What happened?

The answer

Orders already waiting for delivery got stuck, and they are stuck in a way that looks healthy.

When the delivery signal arrives, a worker replays the method against the order's history to rebuild its state. The new code's first step is fraudCheck. The history's first step is charge. They do not match, so the SDK refuses to continue. In the test the history shows WORKFLOW_TASK_FAILED with cause NON_DETERMINISTIC_ERROR, while the status stays RUNNING and the fraud check never runs for that order. Temporal keeps retrying, in the hope that a fixed worker appears, and that is deliberate: nothing is lost. Deploying the old code brings the order back to life and it completes, which the test also confirms. But you have to know to look. By the fourth property in my own definition, this is a weak spot in Temporal: the answer exists, in the history, but the question "is it stuck?" takes more than a look at the status.

Left, model on Camunda 8: an instance started on version 1, version 2 deployed, and it keeps running version 1 until you choose to migrate it. Right, code on Temporal: a workflow started on old code, new code deployed with steps reordered, replay does not match history, and it is stuck with status still RUNNING.
Figure 7. Long-running instances make every release a migration. The two families surface it differently.

The fix is a version marker, which records in the history which path this execution took:

// src/main/java/com/wolinski/orchestration/temporal/PatchedWorkflowImpl.java public String place(String orderId, long deliveryDeadlineSeconds, boolean outOfStock) { if (1 == Workflow.getVersion("fraud-check", Workflow.DEFAULT_VERSION, 1)) { activities.fraudCheck(orderId); } activities.charge(orderId); try { activities.reserve(orderId, outOfStock); } catch (ActivityFailure failure) { if (failure.getCause() instanceof ApplicationFailure cause && "OUT_OF_STOCK".equals(cause.getType())) { activities.refund(orderId); return "cancelled"; } throw failure; } activities.ship(orderId); if (Workflow.await(Duration.ofSeconds(deliveryDeadlineSeconds), () -> delivered)) { return "delivered"; } activities.chase(orderId); return "chased"; }

With that, the old order completes with no fraud check and a new one gets it. Better still, you can catch the mistake before deploying. Temporal's SDK can replay a saved history against new code, and that fails the build on the reordered version and passes on the patched one:

// src/test/java/com/wolinski/orchestration/TemporalTest.java WorkflowReplayer.replayWorkflowExecution(history, OrderWorkflowImpl.class); WorkflowReplayer.replayWorkflowExecution(history, PatchedWorkflowImpl.class); assertThatThrownBy(() -> WorkflowReplayer.replayWorkflowExecution(history, ReorderedWorkflowImpl.class)) .isInstanceOf(RuntimeException.class);

For that to protect you, the histories have to come from real running executions, so saving a sample of them belongs in your release process. Temporal's Java versioning guide also covers a newer alternative based on worker versions, which I did not test.

The same release on Camunda behaves differently, because deploying a new model creates a new version and leaves running instances alone. In the test, an instance started on version 1 goes straight from approval to payment after version 2 is deployed, while a new one goes through the added fraud step. A third, moved explicitly with newMigrateProcessInstanceCommand, takes the new path too, and migration has limits of its own that I did not explore. The catch runs the other way: if version 1 had a bug, every running instance keeps it until someone migrates them. Neither approach makes this free. Any system with processes that run for weeks has this problem, and it is a real part of the cost of having them.

When to reach for it

A decision flow. If one database transaction is enough, use a transaction, there is no process here. Otherwise, if it waits for people or days, orchestrate. Otherwise, if it has many failure paths and people ask where is it, orchestrate; if not, use events and an outbox.
Figure 8. Most of the value comes from the second question.
  • Do not orchestrate one transaction. If everything fits in one database commit, you have no process, and an engine only adds a network hop and a place to fail.
  • Do not orchestrate a request path by reflex. A user waiting on three synchronous calls does not gain much from durability, and pays the latency above.
  • Do orchestrate waiting, once there is enough of it. Timers and people are what status columns handle worst, and engines handle best. One process with one timer can still be a status column and a scheduled job.
  • Do orchestrate when the failure paths outnumber the happy path, and when support keeps asking where things are.
  • Budget for what remains yours. Idempotent steps, a safe start, retry policies, compensations, versioning, keeping data out of the engine, alerts on stuck cases, a definition tied to one engine, and running a critical piece of infrastructure.

Scope and how this was checked

./run.sh runs 33 tests on Temurin JDK 25 in a Docker image pinned by digest, against Camunda 8.10.8, Temporal server 1.32.2 and RabbitMQ 4.4.0, also pinned, using Camunda client 8.10.8 and Temporal SDK 1.40.0. Workers that have to crash run in their own JVM and end in Runtime.halt, and engines are stopped with docker kill, so these are real crashes rather than exceptions. A file standing in for the outside world records each side effect, and the assertions read that file.

The limits matter. Each engine is a single node on a development database: H2 for Camunda and SQLite for Temporal's development server. Neither is what their vendors support for production, and I did not test clustering, failover, upgrades of the engine itself or real load. The latency figures come from one laptop. Having both SDKs in one JVM required forcing a single gRPC version, which a real service would not need. And the definition at the top is mine. Vendors draw the line elsewhere, and Temporal itself uses "durable execution" rather than this term.

Related Articles