Camunda Zeebe
Experimental module
The experimental module is fully working and tested, but requires additional validation and usage analytics.
For this reason, its API may potentially undergo minor changes before becoming fully stable.
The module connects a Camunda 8 (Zeebe) client and
creates job workers for an external process orchestrator. In Kora, such a worker is declared as a regular component:
a method annotated with @JobWorker receives process variables, performs work, and returns a result that is sent back
to Zeebe.
The module is built on the io.camunda:camunda-client-java client, so the client type is io.camunda.client.CamundaClient
and its command responses live in the io.camunda.client.api.response package.
Dependency¶
Dependency build.gradle:
Module:
Dependency build.gradle.kts:
Module:
Configuration¶
Example of a complete client configuration described in the ZeebeClientConfig class (example values or default values are specified):
zeebe {
client {
executionThreads = 4 //(1)!
keepAlive = "45s" //(2)!
certificatePath = "/file/path/to/cert.crt" //(3)!
initializationFailTimeout = "15s" //(4)!
grpc {
url = "http://localhost:26500" //(5)!
ttl = "1h" //(6)!
maxMessageSize = "4MiB" //(7)!
retryPolicy {
enabled = true //(8)!
attempts = 5 //(9)!
delay = "100ms" //(10)!
delayMax = "5s" //(11)!
step = 3.0 //(12)!
}
}
rest {
url = "http://localhost:8080" //(13)!
}
deployment {
resources = "classpath:bpm" //(14)!
timeout = "45s" //(15)!
}
telemetry {
logging {
enabled = false //(16)!
}
metrics {
enabled = false //(17)!
slo = [ 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000 ] //(18)!
tags = { //(19)!
"key1" = "value1"
"key2" = "value2"
}
}
tracing {
enabled = true //(20)!
attributes = { //(21)!
"key1" = "value1"
"key2" = "value2"
}
}
}
}
}
- Maximum number of threads for job workers (default: twice the number of available processors, but not less than
2) - Time without read activity before sending a
KeepAlivecheck (default:45s) - File path to the certificate for the connection; if not specified, the system certificate is used (optional)
- Maximum time to wait for the topology availability check on client startup; if not specified, the check is skipped (optional)
URLof theZeebegRPCgateway (required, no default)- How long the message should be kept on the broker when sent through
gRPC(default:1h) - Maximum inbound message size for
gRPC(default:4MiB) - Whether the retry policy for the
gRPCconnection is enabled (default:true) - Number of attempts (default:
5) - Initial delay between attempts (default:
100ms) - Maximum delay between attempts (default:
5s) - Delay multiplier between attempts (default:
3.0) URLof theZeebeRESTgateway (required, no default)- Paths for searching resources that will be uploaded to the orchestrator after startup (default:
[]) - Maximum time to wait for resource upload (default:
45s) - Enables module logging (default:
false) - Enables module metrics (default:
false) - Configures SLO for metrics (default:
io.koraframework.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO) - Configures tags for metrics (default:
{}) - Enables module tracing (default:
true) - Configures attributes for tracing (default:
{})
zeebe:
client:
executionThreads: 4 #(1)!
keepAlive: "45s" #(2)!
certificatePath: "/file/path/to/cert.crt" #(3)!
initializationFailTimeout: "15s" #(4)!
grpc:
url: "http://localhost:26500" #(5)!
ttl: "1h" #(6)!
maxMessageSize: "4MiB" #(7)!
retryPolicy:
enabled: true #(8)!
attempts: 5 #(9)!
delay: "100ms" #(10)!
delayMax: "5s" #(11)!
step: 3.0 #(12)!
rest:
url: "http://localhost:8080" #(13)!
deployment:
resources: "classpath:bpm" #(14)!
timeout: "45s" #(15)!
telemetry:
logging:
enabled: false #(16)!
metrics:
enabled: false #(17)!
slo: [ 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000 ] #(18)!
tags: #(19)!
key1: value1
key2: value2
tracing:
enabled: true #(20)!
attributes: #(21)!
key1: value1
key2: value2
- Maximum number of threads for job workers (default: twice the number of available processors, but not less than
2) - Time without read activity before sending a
KeepAlivecheck (default:45s) - File path to the certificate for the connection; if not specified, the system certificate is used (optional)
- Maximum time to wait for the topology availability check on client startup; if not specified, the check is skipped (optional)
URLof theZeebegRPCgateway (required, no default)- How long the message should be kept on the broker when sent through
gRPC(default:1h) - Maximum inbound message size for
gRPC(default:4MiB) - Whether the retry policy for the
gRPCconnection is enabled (default:true) - Number of attempts (default:
5) - Initial delay between attempts (default:
100ms) - Maximum delay between attempts (default:
5s) - Delay multiplier between attempts (default:
3.0) URLof theZeebeRESTgateway (required, no default)- Paths for searching resources that will be uploaded to the orchestrator after startup (default:
[]) - Maximum time to wait for resource upload (default:
45s) - Enables module logging (default:
false) - Enables module metrics (default:
false) - Configures SLO for metrics (default:
io.koraframework.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO) - Configures tags for metrics (default:
{}) - Enables module tracing (default:
true) - Configures attributes for tracing (default:
{})
Both addresses are required
The client always opens a gRPC channel and always reads the REST address, so zeebe.client.grpc.url
and zeebe.client.rest.url must both be present. A configuration that declares only one of the two
fails on application startup.
The connection scheme is taken from grpc.url: http opens a plaintext channel, https opens a TLS channel.
For a self-signed certificate, point certificatePath at the certificate file; otherwise the system trust store is used.
The default Zeebe ports are 26500 for gRPC and 8080 for REST.
The gRPC channel has its own telemetry section zeebe.client.grpc.telemetry with the same options as any
gRPC client; it describes the transport calls to the orchestrator, while
zeebe.client.telemetry describes the job workers themselves.
Job worker logs are written to the io.koraframework.camunda.zeebe.worker.<jobType> logger, so log levels can be raised
for a single worker; at the DEBUG level the log record also contains the job variables.
Module metrics are described in the Metrics Reference section.
Resource deployment¶
If deployment.resources contains paths, the module finds resources on the classpath during startup and deploys them to
Zeebe through the ZeebeResourceDeployment component. Both BPMN processes and DMN decisions found under the
configured locations are deployed. Only paths with the classpath: prefix are supported, for example classpath:bpm;
other locations are logged and skipped.
Put the deployable resources under the corresponding classpath directory:
- One or more classpath locations to scan for
BPMN/DMNresources (a single value or a list)
Client¶
The module creates a CamundaClient component that can be injected into your own services when you need to manually start
processes, publish messages, or execute other Zeebe commands.
For example, to start a new process instance:
@Component
public final class ProcessStarter {
private final CamundaClient client;
public ProcessStarter(CamundaClient client) {
this.client = client;
}
public void start() {
ProcessInstanceEvent event = client.newCreateInstanceCommand()
.bpmnProcessId("demo") //(1)!
.latestVersion() //(2)!
.variables("{\"startId\":\"42\"}") //(3)!
.send()
.join(); //(4)!
}
}
BPMNprocess identifier of the process to start- Start the latest deployed version of the process
- Initial process variables as a
JSONstring (aMapor a@Jsonobject are also accepted) - Send the command and block until
Zeebeacknowledges it (send()returns aCamundaFuture, which is also aCompletionStage)
@Component
class ProcessStarter(private val client: CamundaClient) {
fun start() {
val event = client.newCreateInstanceCommand()
.bpmnProcessId("demo") //(1)!
.latestVersion() //(2)!
.variables("""{"startId":"42"}""") //(3)!
.send()
.join() //(4)!
}
}
BPMNprocess identifier of the process to start- Start the latest deployed version of the process
- Initial process variables as a
JSONstring (aMapor a@Jsonobject are also accepted) - Send the command and block until
Zeebeacknowledges it (send()returns aCamundaFuture, which is also aCompletionStage)
The same client publishes messages (client.newPublishMessageCommand()), deploys resources
(client.newDeployResourceCommand()), and executes any other Zeebe command.
Client customization¶
The CamundaClient can be tuned with optional graph components that the module picks up automatically:
CredentialsProvider— authorization forZeebe(Camunda 8 SaaSor self-managed withOAuth);JsonMapper— customJSONmapper (io.camunda.client.api.JsonMapper) used byCamundaClientfor variable (de)serialization;ScheduledExecutorService— executor used by job workers;ClientInterceptor— everygRPCinterceptor declared without a@Tagis collected and applied to theZeebechannel, in addition to the module's own telemetry interceptor. Interceptors that belong to another client must therefore be tagged with their service so that they do not end up on theZeebechannel.
For example, to authenticate against Camunda 8 with OAuth, provide a CredentialsProvider bean:
@Module
public interface ZeebeAuthModule {
default CredentialsProvider zeebeCredentialsProvider() {
return CredentialsProvider.newCredentialsProviderBuilder()
.clientId("client-id")
.clientSecret("client-secret")
.audience("zeebe.camunda.io")
.authorizationServerUrl("https://login.cloud.camunda.io/oauth/token")
.build();
}
}
@Module
interface ZeebeAuthModule {
fun zeebeCredentialsProvider(): CredentialsProvider =
CredentialsProvider.newCredentialsProviderBuilder()
.clientId("client-id")
.clientSecret("client-secret")
.audience("zeebe.camunda.io")
.authorizationServerUrl("https://login.cloud.camunda.io/oauth/token")
.build()
}
Worker¶
Worker is a handler that can perform a specific job in a process.
When a process contains a job of the required type, Zeebe activates it and passes it to one of the workers.
Configuration¶
There is a default configuration that is applied to all workers on creation, and then named settings for a concrete
worker are applied on top of it by worker type (Type).
To change settings for all workers at once, override the default section.
To change settings only for one worker, add a section with the type name specified in @JobWorker.
If the zeebe.worker.job section is not specified, the built-in default configuration is used.
Example of a complete worker configuration described in the ZeebeWorkerConfig class (example values or default values are specified):
zeebe {
worker {
job {
default { //(1)!
enabled = true //(2)!
name = "default" //(3)!
timeout = "15m" //(4)!
maxJobsActive = 32 //(5)!
requestTimeout = "15s" //(6)!
pollInterval = "100ms" //(7)!
tenantIds = [] //(8)!
streamEnabled = false //(9)!
streamTimeout = "15s" //(10)!
backoff {
minDelay = "100ms" //(11)!
maxDelay = "500ms" //(12)!
factor = 1.0 //(13)!
jitter = 1.1 //(14)!
}
}
}
}
}
- Worker type (
Type) or the default settings namedefault - Whether the worker is enabled (default:
true) - Name the worker is registered under on the broker (default:
default) - Maximum time for one job execution by the worker (default:
15m) - Maximum number of jobs that will be activated simultaneously for this worker; used to align job fetching speed with processing speed (
backpressure) (default:32) - Request timeout used for polling a new job by the worker (default:
15s) - Maximum interval between polling new jobs; if no jobs are activated after work is completed, the worker periodically polls the broker (default:
100ms) tenantidentifiers for which the worker can receive jobs (default:[])- Whether to use streaming together with polling for job activation (default:
false) - Maximum stream lifetime when streaming is enabled (default:
15s) - Minimum retry delay; due to
jitter, the actual delay can be lower than this minimum (default:100ms) - Maximum retry delay; due to
jitter, the actual delay can exceed this value (default:500ms) - Delay multiplication factor: the previous delay is multiplied by this value (default:
1.0) jitterfactor: the next delay is randomly changed within the+/-range of this factor (default:1.1)
zeebe:
worker:
job:
default: #(1)!
enabled: true #(2)!
name: "default" #(3)!
timeout: "15m" #(4)!
maxJobsActive: 32 #(5)!
requestTimeout: "15s" #(6)!
pollInterval: "100ms" #(7)!
tenantIds: [] #(8)!
streamEnabled: false #(9)!
streamTimeout: "15s" #(10)!
backoff:
minDelay: "100ms" #(11)!
maxDelay: "500ms" #(12)!
factor: 1.0 #(13)!
jitter: 1.1 #(14)!
- Worker type (
Type) or the default settings namedefault - Whether the worker is enabled (default:
true) - Name the worker is registered under on the broker (default:
default) - Maximum time for one job execution by the worker (default:
15m) - Maximum number of jobs that will be activated simultaneously for this worker; used to align job fetching speed with processing speed (
backpressure) (default:32) - Request timeout used for polling a new job by the worker (default:
15s) - Maximum interval between polling new jobs; if no jobs are activated after work is completed, the worker periodically polls the broker (default:
100ms) tenantidentifiers for which the worker can receive jobs (default:[])- Whether to use streaming together with polling for job activation (default:
false) - Maximum stream lifetime when streaming is enabled (default:
15s) - Minimum retry delay; due to
jitter, the actual delay can be lower than this minimum (default:100ms) - Maximum retry delay; due to
jitter, the actual delay can exceed this value (default:500ms) - Delay multiplication factor: the previous delay is multiplied by this value (default:
1.0) jitterfactor: the next delay is randomly changed within the+/-range of this factor (default:1.1)
To override settings for a single worker, add a section keyed by the worker type (Type)
declared in @JobWorker. A named section is merged over default, which in turn is merged over the built-in defaults,
so a named section only needs to list the keys it changes. Setting enabled = false on a named type disables just that
one worker.
zeebe {
worker {
job {
foo { //(1)!
timeout = "30s"
maxJobsActive = 8
}
bar { //(2)!
enabled = false
}
}
}
}
- Overrides only
timeoutandmaxJobsActivefor the@JobWorker("foo")worker; all other settings come fromdefault - Disables the
@JobWorker("bar")worker while leaving the rest of the configuration untouched
Declarative¶
You can declaratively create workers that perform work
within the Zeebe orchestrator.
The @JobWorker annotation specifies the worker type (Type)
from the process. Zeebe uses this value to connect a job from a BPMN process with a handler in the application.
Worker methods must satisfy the following requirements:
- The enclosing class must be a component in the dependency graph, for example annotated with
@Component. - The method must not be
private— compilation fails with@JobWorker method can't be private. - The method may only declare
@JobVariable,@JobVariables, andJobContextparameters — any other parameter type is rejected at compile time. The rawJobClientandActivatedJobare available only in the imperative worker. - A variable name (both for arguments and for the result) must be alphanumeric, may contain
_, must not start with a digit, and must not be aFEELkeyword such asif,then,else,forornot.
For each annotated method Kora generates a separate KoraJobWorker component and registers it on the broker at startup.
If several workers declare the same type, all of them are registered and a warning is written to the log.
Parameter context¶
You can inject the job context as a method argument.
JobContext contains metadata of the current job, worker, and process.
JobContext exposes the following read-only accessors:
| Method | Description |
|---|---|
jobKey() |
Unique key of the activated job |
jobName() |
Name the worker is registered under on the broker (the name configuration option) |
jobType() |
Job type of the activated job as defined in the BPMN process |
jobWorker() |
Name of the worker that activated the job on the broker side |
tenantId() |
Tenant identifier the job belongs to |
processId() |
BPMN process identifier |
processInstanceKey() |
Key of the process instance the job belongs to |
processDefinitionVersion() |
Version of the deployed process definition |
processDefinitionKey() |
Key of the deployed process definition |
elementId() |
Identifier of the BPMN element the job was created for |
elementInstanceKey() |
Key of the BPMN element instance |
headers() |
Custom headers defined on the job in the BPMN model |
retryCount() |
Number of remaining retries for the job |
deadline() |
Moment (Instant) until which the job is exclusively assigned to the worker |
deadlineAsMillis() |
Same deadline expressed as epoch milliseconds |
variablesAsString() |
Raw job variables as a JSON string |
The context of the job being handled is also bound to the JobContext.VALUE
scoped value for the whole duration of the call, so code that is called from a worker can
read the job metadata through JobContext.VALUE.get() without passing the argument down the call stack.
Parameter variable¶
You can inject process variables as method arguments. A process variable is part of the process state and can be set on process start or as part of the worker result.
If at least one variable is specified through @JobVariable, the generated worker asks Zeebe only for those variables.
If @JobVariable is not used, the worker asks for all job variables.
You can specify the variable name explicitly in @JobVariable, or the method argument name will be used by default.
A variable argument is required by default: if the process does not provide it, the job fails.
Mark the argument as nullable (@Nullable in Java, T? in Kotlin) to accept a missing or null variable.
Since process variables are passed as JSON, the method argument can be a user type that has JsonReader and JsonWriter
available.
Parameter variables¶
You can inject multiple process variables as one method
argument through @JobVariables. This argument represents all job variables as one JSON object.
Only one @JobVariables argument is allowed per worker method.
Result¶
You can not only execute work, but also return the result as variables to the process context.
The result can be returned as a Map<String, Object> that describes the JSON response structure.
You can also return a named result as a single variable. This is equivalent to one key and value in a
Map<String, Object> object.
In this case, it is obligatory to specify the name of the variable in the @JobVariable annotation:
Errors¶
If you need to complete execution with a process error, throw JobWorkerException from the
io.koraframework.camunda.zeebe.worker.exception package.
The exception can contain an error code, message, and process variables if they are required.
This exception is converted to a throwError command for Zeebe: the getCode(), message, and getVariables()
of the exception are sent as the error code, error message, and variables of the command, and the BPMN model decides
how to handle the error, for example through a boundary error event.
Any other exception is a technical failure rather than a process error: the module wraps it into a JobWorkerException
with one of the built-in codes below and lets it propagate. The job is then failed on the broker and reactivated
according to the worker backoff settings until the retries are exhausted and an incident is created.
| Code | When it is used |
|---|---|
SERIALIZATION |
The worker result could not be written into process variables |
UNEXPECTED |
Any other error from the worker method, including a job variable that could not be read into an argument |
Imperative¶
You can also create lower-level workers and work directly with CamundaClient contracts.
To do that, the component must implement the KoraJobWorker interface.
@Component
public final class SomeJob implements KoraJobWorker {
@Override
public String type() {
return "someJobType";
}
@Override
public List<String> fetchVariables() {
return List.of("startId"); //(1)!
}
@Override
public FinalCommandStep<?> handle(JobClient client, ActivatedJob job) {
return client.newCompleteCommand(job); //(2)!
}
}
- Only these variables are fetched from
Zeebe; return an empty list (the default) to fetch all variables - The returned command is sent by the module after the telemetry of the call is recorded
@Component
class SomeJob : KoraJobWorker {
override fun type(): String = "someJobType"
override fun fetchVariables(): List<String> = listOf("startId") //(1)!
override fun handle(client: JobClient, job: ActivatedJob): FinalCommandStep<*> {
return client.newCompleteCommand(job) //(2)!
}
}
- Only these variables are fetched from
Zeebe; return an empty list (the default) to fetch all variables - The returned command is sent by the module after the telemetry of the call is recorded
The fetchVariables() method is the imperative analogue of @JobVariable: it controls which process variables Zeebe
sends with the job. By default it returns an empty list, which fetches all variables; returning a non-empty list limits
the payload to just those variables. Unlike declarative workers, handle receives the raw JobClient and ActivatedJob
and decides itself which command finishes the job — client.newCompleteCommand(job) to complete it, or
client.newThrowErrorCommand(job).errorCode("DOESNT_WORK").errorMessage("...") to raise a BPMN error.
An exception thrown out of handle fails the job instead, and the broker reactivates it according to the backoff settings.
Signatures¶
Available signatures for worker methods out of the box:
The T refers to the type of the return value or void.
If the result is null or Optional.empty(), the job is completed without adding variables.
void myMethod()T myMethod()Optional<T> myMethod()
Asynchronous signatures are not supported: CompletionStage, Future and Publisher / Mono return types are
rejected at compile time with Async invocation is not supported.
By T we mean the type of the return value, either T, T? or Unit.
If the result is null, the job is completed without adding variables.
myMethod()myMethod(): TmyMethod(): T?
Asynchronous signatures are not supported: suspend functions and Deferred, Mono, Flux and CompletionStage
return types are rejected at compile time.