Repository navigation
Workflow tasks
A method annotated with @WorkflowTask processes a task of a BPMN model (BPMN = Business Process Model and
Notation), for example a service task or a send task. VanillaBP connects the method to the task in one of two ways.
By convention, the name of the method matches the task definition in the BPMN model. Or you say it explicitly, with
@WorkflowTask(taskDefinition = "..."), or with @WorkflowTask(id = "...") to use the BPMN activity ID.
@WorkflowTask
public void processPayment(final Ride ride) {
// load nothing, save nothing: the aggregate is loaded before and saved after
ride.setPaymentProcessed(true);
}-
The execution contract
What VanillaBP does around your method, on every BPMS (business process management system) and every platform. -
What happens when my handler throws?
The three outcomes, the one annotation of yours which breaks them, and where an error goes when the called process does not catch it. -
A repeated delivery does not run your handler again
The delivery record, what it remembers, and how long it is kept. -
Parameters
What a handler may take, and what a@TaskParamis for. -
Completing and canceling asynchronous tasks
A task which returns before the work is done, and how it is finished later. -
Two task events, and the life cycle BPMN describes
WhyTaskEvent.Eventhas two values, although the specification describes eleven states. -
An open task and how old it is
What VanillaBP measures about a task nobody completes, and what it can do about it. -
User tasks
What is different about a task a person completes. -
Wiring validation at startup
What the start checks between your BPMN and your methods, why a process belongs to one workflow aggregate, and what the start says when something does not match. -
A handler method VanillaBP does not see
Two ways to write a method which is right there in your source and never runs. -
BPMS specifics
The rest is on the adapters' own pages. -
Versions of a process
How one code base serves several generations of a model, what that costs, and how a renamed process is still served. -
Task-scoped adapter configuration
Adapter properties which you can set for a single task.
Whenever the BPMS delivers a task, VanillaBP runs the handler in one transaction. It loads the workflow aggregate by its ID, calls the method with the bound parameters and saves the aggregate. You never manage the transaction or the persistence yourself. The same contract holds on every BPMS adapter and every platform. (why)
There are exactly three outcomes:
| Handler behavior | Task | Aggregate changes | Use it for |
|---|---|---|---|
| returns normally | completed | committed | the regular case |
throws TaskException
|
completed with a BPMN error (the exception's error code) | committed | expected business errors modeled as error boundary events in the BPMN |
| throws anything else | not completed | rolled back | unexpected technical failures, where the BPMS' retry semantics apply |
Notes:
- A
TaskExceptionis not a failure (why). The error code sends the workflow through an error boundary event, and everything the handler changed on the aggregate is persisted. (In VanillaBP 1 this needed@Transactional(noRollbackFor = TaskException.class)on the service. In version 2 VanillaBP manages the transaction and this behavior is built in. So do not add your own@Transactionalto@WorkflowTaskmethods.) - Any other exception rolls the transaction back. The aggregate stays untouched and the task stays open. The BPMS decides what happens next, usually a retry of the same task. The adapter's documentation describes its retry configuration. The contract "exception = retry, no partial state" is the same on all adapters.
- The commit itself can fail too. As soon as a process holds more than one token, a second branch may write the same workflow aggregate. If the aggregate has a version attribute, this collision shows up in the commit VanillaBP makes. Two writers on one aggregate describes what happens then and how to avoid the collision in the first place.
- A remote BPMS may call a handler at least once. VanillaBP remembers what it processed and answers a repeated delivery from that record, see A repeated delivery does not run your handler again. For everything a record cannot cover, the old rule still holds: base business decisions on the state of the aggregate. Whether this case happens at all depends on the BPMS, see your adapter's wiki.
The error code is not a message to your application. It is a trigger in the model, and the model decides where it goes. BPMN defines the rule. The error is caught by the error boundary event of the nearest enclosing activity whose error code matches. A boundary event which names no code at all matches too, because it catches any error. A boundary event further out only sees the error when nothing closer to the throw caught it, and nothing in between runs. An error boundary event always interrupts, because the activity it sits on could not be completed. An error end event inside a subprocess ends everything still running in that subprocess, not only the branch which reached it.
VanillaBP changes none of that and adds nothing to it. It only gives your code a way in. No code of VanillaBP
reads the error boundary events of your model. None of it checks that the code you throw can be caught
somewhere. There is no API to catch or re-route an error from the application side. The code travels to the
engine as you wrote it. It is only translated the way every other identifier is: under use-prefix an error
code carries the workflow module id, both where the model declares it and where your handler throws it, see
what prefixing does.
A TaskException can carry an error name next to the code, new TaskException("Documents missing", "documents-missing"). The model catches the error by its code. The name travels to the engine as the message of
the error, so the name is what an operator reads. The constructor with one argument uses the code for both.
The blueprint bpmn-error-escalation
shows the whole path in one model. It runs unchanged on both Camunda engines.
And what if nothing catches the error? BPMN leaves only this open, and the engines fill the gap in different ways. One ends the workflow quietly, another holds it at an incident. Your application is not told in either case, because VanillaBP never learns about it. The adapter's Deviations page says what your BPMS does. Read it before a model relies on a code being caught somewhere.
BPMN has a second way to say that something did not go as planned. An error says that the activity cannot be
completed. An escalation says that somebody should know, while the work carries on. VanillaBP gives your code no
way to raise the second one. A TaskException carries an error code and nothing else, and no method of
ProcessService raises an escalation.
A model can still escalate, because an event throws an escalation, not a program. The blueprint above has one. Inside a subprocess, an escalation throw event reports the large amount. A non-interrupting boundary event catches it, and a separate branch informs the supervisor while the subprocess carries on. No line of Java knows that this happens. If the decision to escalate depends on data, write that data onto the workflow aggregate. Then let a gateway in front of the throw event read it.
VanillaBP treats escalations like message and signal names. Under use-prefix an escalation code is deployed
with the workflow module id in front of it, so two modules do not take each other's escalations, see
what prefixing does. No other part of VanillaBP
looks at an escalation.
The three outcomes above hold as long as VanillaBP owns the transaction of the handler. A transaction annotation of your application joins that transaction. Its interceptor marks the transaction rollback-only the moment an exception passes it. For the interceptor, a TaskException is an ordinary RuntimeException. So the aggregate changes which VanillaBP promises to commit are lost. Neither Spring nor JTA (Jakarta Transactions) can clear that mark afterwards, so VanillaBP cannot repair the situation. It makes it visible instead:
-
The application does not start when a transaction annotation which your platform honors covers a
@WorkflowTaskmethod. It does not matter where the annotation sits: on the method, the class, a superclass, an interface, or a custom annotation of yours which carries it. The message names the class, the method and the ways to fix it. Methods which callProcessServicestill need a transaction of their own, so annotate those methods instead of the whole class. -
The platform decides which annotations count, not VanillaBP. Spring Boot honors its own
@Transactional,jakarta.transaction.Transactionalandjakarta.ejb.TransactionAttribute. Quarkus honorsjakarta.transaction.Transactional. It honors Spring's@Transactionalonly with the extensionquarkus-spring-tx, which maps it onto the JTA annotation, rollback rules included. Without that extension, the annotation does nothing on Quarkus, and@TransactionAttributehas no meaning there at all. An annotation your platform does not honor never stops the start. It produces a warning naming the method, because the transaction boundary you declared does not exist anywhere in your application. -
A rollback rule which excludes
TaskExceptionis accepted instead of removing the annotation. A VanillaBP 1 application already has it:@Transactional(noRollbackFor = TaskException.class)for the Spring annotation,@Transactional(dontRollbackOn = TaskException.class)for thejakarta.transactionone. A propagation which does not join an existing transaction (REQUIRES_NEW,NEVER,NOT_SUPPORTED) is accepted as well. Your rollback then stays inside your own transaction. -
The task fails with a message which tells you what to do when the annotation sits on a bean the handler calls. No startup check can see that case. After the handler ran, VanillaBP asks the transaction for its state. So the silent data loss becomes an error which names the workflow module, the BPMN process, the task and the handler method. This also covers the case without any
TaskException. Think of a handler which catches the exception of a nested transactional call and returns normally. Without this check, the task would be reported as completed while nothing was persisted.
javax.transaction.Transactional from Spring Boot 2 is the most common annotation which nobody honors any more. It stopped working with the move to the Jakarta namespace. VanillaBP names the method in a warning. Check the rest of your application for the same annotation too, because there it does nothing just as silently.
The model decides where the code of your TaskException is caught. The search for a catching event does not
stop at the process the task belongs to. If a called process catches the code nowhere, it hands the error up to
the call activity which started it. An error boundary event drawn there catches it. BPMN asks for exactly that.
We checked it on a Camunda 8 cluster, with a called process which raised the code and a caller which caught it
on the boundary.
So the error your handler raises can end a called process and send the caller on a different path. Everything the handler wrote on the aggregate is still committed. The aggregate is the same one on both sides of the call, as long as the called process works on it. The wiring validation describes that rule.
One construct does not travel that way on Camunda 8. A compensation throw event in the caller does not reach the compensation handlers of a called process. If you want to undo work across the call boundary, the model has to arrange it. Compensation has the rest. The exception itself is described on the Camunda 8 adapter's Deviations page.
A remote BPMS hands a task out again whenever it did not learn the result. Say your handler committed, but the process crashed before the result reached the BPMS. For the BPMS, the task is still open. So VanillaBP writes a record of every processed delivery in the transaction of the handler. It answers a repeated delivery from that record instead of calling the method a second time. The BPMS gets the recorded outcome again, which is what it was missing. (why)
vanillabp:
adapters:
my-bpms:
deduplicate-deliveries: false # default: trueThe switch belongs to an adapter. So you may also set it for a workflow module, for a single workflow and for a single task, see the levels a setting is written at. Turn it off where your handlers are idempotent anyway and you would rather see them run.
The records live in the same database as your workflow aggregates. VanillaBP creates a table
VANILLABP_TASK_DELIVERY or a collection vanillabp-task-deliveries for them. On MongoDB,
vanillabp.outbox.mongo.delivery-collection gives that collection a name of your own. On a relational database,
vanillabp.outbox.jdbc.delivery-table does the same for the table. The name you pick has to differ from the
names of the outbox and its payloads, because they are in the same database. Two stores in one place would read
and delete each other's records. So the start ends in that case and names both keys. The table is created unless
vanillabp.outbox.create-schema says otherwise. vanillabp.delivery.retention decides how long a record is kept,
seven days by default. After that period the record is gone, and a later repeated delivery reaches your handler
again.
Both prefixes belong here, and they split the work the same way wherever they turn up. Everything about the store
is written under vanillabp.outbox: its name, whether VanillaBP creates it, and whether the default exists at
all. That is because VanillaBP builds this store the same way as the store the outbox writes to. The period is
written under vanillabp.delivery. It decides whether a late redelivery runs your code again, and that is more
than the outbox' own retention decides.
Which tasks does a read of this table name? Not all of them, and that is worth saying clearly. The table holds the
work your application was handed. So a task only gets into it when a @WorkflowTask method served it. A user task
without a method is delivered and completed like any other, but it leaves no record, and no read will name it.
That is a valid way to model. Take an order like "set the machine to x". It can be a real user task which no
software can check, and it changes nothing about the aggregate except that somebody did it. The process and the
task list carry it. If your extension has to name such tasks as well, it has to ask the BPMS. This table exists
to save exactly that round trip.
VanillaBP creates the indexes it uses to read this table, and one of them is worth knowing about. An extension which asks for the open tasks of a workflow reads by the workflow aggregate. The column with the aggregate id is 1024 characters wide. MySQL with utf8mb4 and DB2 with 4K pages do not allow that much in an index key. So the shipped index narrows the read to the open records, not to one aggregate. The cost of the read grows with the number of tasks your whole application holds open at one time. It does not grow with the number of tasks of the workflow you ask about.
We measured it in September 2026, on PostgreSQL 16.15 and on MongoDB 7.0.40, with the table in memory. Each figure is the median of a thousand calls, and every call answered three open tasks:
| concurrently open tasks | PostgreSQL | MongoDB |
|---|---|---|
| 500 | 0.22 ms | 0.29 ms |
| 5000 | 0.68 ms | 0.28 ms |
| 50000 | 5.73 ms | 0.27 ms |
| 100000 | 14.70 ms | 0.27 ms |
| 500000 | 34.30 ms | 0.27 ms |
MongoDB indexes the aggregate id itself, because its engine allows that. So its answer depends on the size of your question, not on the size of your collection. On PostgreSQL the read follows what your application holds open. Up to ten thousand open tasks it costs about a millisecond, which is nothing next to the round trip to your BPMS. At fifty thousand it is still under six milliseconds. Past a hundred thousand, PostgreSQL stops using the index and reads the whole table. From there on, the history you have collected counts as well.
Below that point the history hardly counts. Five hundred open tasks were answered in 0.2 ms next to two hundred thousand finished ones, and in 3.2 ms next to a million. A second million changed nothing, so this is a step and not a slope. The read does not get longer. PostgreSQL starts two parallel workers, because it misjudges how much of the table is open, and that causes the step.
If your application reaches those sizes, add an index over AGGREGATE_ID yourself, in the syntax of your
database. On PostgreSQL, where we measured it, CREATE INDEX VANILLABP_TASK_DELIVERY_AGG ON VANILLABP_TASK_DELIVERY (AGGREGATE_ID) brought every size in the table above to 0.14 ms. MySQL needs a prefix length, AGGREGATE_ID(255).
DB2 with 4K pages needs a table space with larger pages. And please open an issue. Then we learn at which sizes
this really happens, and whether VanillaBP should ship that index itself.
This number decides correctness, so it has a property of its own. (why) It used to be
vanillabp.outbox.retention. On the outbox side, that number no longer decides anything about correctness.
Deduplication there ends with the dispatch. The retention only keeps a dispatched entry readable for whoever looks
at the table during support. An installation which shortened it to keep the outbox table small shortened this
window at the same time. If you do not set vanillabp.delivery.retention, it still follows
vanillabp.outbox.retention. So nothing changes when you upgrade, and VanillaBP says at startup which of the two
applies. Unlike the rest of vanillabp.delivery.*, this one is read globally. You cannot set it per workflow
module, because one cleanup per store deletes by age across the whole table.
How long is long enough? It has to cover the largest gap between a handler run and the last redelivery of that work. The part nobody can compute for you is how long your application is stopped. A stopped application refreshes nothing, and the first cleanup after the next start deletes what expired in the meantime. No adapter can put a limit on that either. An adapter knows how often it hands unacknowledged work out again, which is minutes to hours. That says nothing about the whole time span. The period counts from the last delivery which VanillaBP answered from that record. So a task your application leaves open keeps its record for as long as the BPMS asks about it.
A record may also live longer than its retention, and that is on purpose. The cleanup looks once an hour. It only deletes when a delivery was recorded since its last run. So an application which records nothing is never woken up for a statement which would find nothing. The last batch before the application went quiet stays behind, until records are written again or the application restarts. Keeping a record longer is the safe side of this window. A record which is still there answers a redelivery. If the record is gone, your method runs again.
When you replace the backend of a BPMS, you have to do one thing. A record is stored under the adapter id, together with the identity the BPMS gave that delivery. So two configured BPMS never answer for each other, however alike their identities look. But this key cannot catch one case: an adapter id whose backend was rebuilt. A restored or newly created installation starts handing out its identities from the beginning again. So a record written before the rebuild can answer a delivery made after it. That task is then skipped. Your handler is not called, and the log says nothing. So delete the records of that adapter id when you replace its backend. For a BPMS which can be rebuilt that way, its adapter's wiki names the step and the statement to run (Camunda 8: replacing a cluster). Deleting costs nothing but the deduplication window, because a record only ever speaks about committed work.
A task whose handler declared @TaskId waits for your completeTask. That call has to reach the BPMS which holds
the task. The migration feature is built on asking every configured BPMS for it. On a remote BPMS
each question is a round trip. On Camunda 8 it is the same command which the adapter sends again a moment later, to
check that the task is still there.
The record of that task's delivery answers the same question. It names the BPMS which handed the task out. It lives
in the database of your workflow aggregate, and it is read inside the transaction you are in anyway. So every
instance of a cluster sees it. Once your completion reached the BPMS, VanillaBP notes
that in the record too. That is why a second completeTask is still a no-op with a warning, as it always was, again
without asking anybody.
(why)
Nothing changes for you. Where there is no record, VanillaBP asks the adapters exactly as before. That is the case
with deduplicate-deliveries: false, after the retention deleted the record, and for tasks which were already open
when you deployed this version. It is also the case on an embedded engine like Camunda 7. Camunda 7 writes no
records at all, because it delivers inside your transaction and loses nothing when it is asked.
A push into the scope of a task is routed the same way. aggregateChanged(aggregate, taskId) writes its values into
the workflow, not into the task. But the task it names is open, and the BPMS which holds an open task also holds the
workflow around it. So that call is answered from the record too, and on Camunda 8 this saves a search of the
cluster. The overload without a task id has nothing to look up, so it asks the adapters as it always did. Once the
task is completed, the record no longer answers for the push. A task which is over says nothing about a workflow
which may well still be running.
The record does not change two things. The dispatch after your commit still finds the BPMS by asking. So a workflow which moved to another BPMS in the meantime is still found. And a task may disappear between the delivery and your call, for example because an interrupting boundary event fired or the instance was terminated. Your call then still fails right away, because the adapter checks that before your transaction commits.
The metric vanillabp.task.elections.from.record counts how often the record answered, see
observability.
Applications which manage their database schema themselves need the four columns this uses: TASK_ID,
TASK_CLOSED_AT, BPMN_ELEMENT_ID and WORKFLOW_ID. They also need an index on TASK_ID and one on
WORKFLOW_ID. The changelog of io.vanillabp:vanillabp-schema ships all of them, and VanillaBP names them at
startup if they are missing. BPMN_ELEMENT_ID says which element of the model a record belongs to. With it, an
extension can show a task in its place in the model. WORKFLOW_ID says in which workflow of the BPMS the task ran.
With it you can follow a task into the tools of that engine. VanillaBP also uses it to read the open tasks of one
workflow whenever a job of that workflow arrives. That read happens per job, not per screen. This is why this
column has an index and the aggregate's id does not.
A row of this table is either a processed delivery or the start of a workflow. The column
RECORD_KIND says which of the two. A start row carries the workflow aggregate and the id which that
workflow has in your BPMS. It says nothing about a task, so no answer about open work counts it.
The row saves VanillaBP a question it has to answer for every operation on a workflow: which of your configured BPMS holds it? Without the row, VanillaBP asks each of them in turn. Some BPMS answer such a question from a read model which their exporter fills. Such a BPMS cannot answer at all in the first seconds after a start. With the row, the answer is a lookup in your own database.
You do not write the row, and you do not read it unless you want to. It is written when a workflow starts. Where your BPMS reports the start itself, the row is written in the transaction which persists the aggregate. Where VanillaBP started the workflow, the row is written right after the start reached your BPMS. One thing is worth knowing about the second case. A workflow of a remote BPMS only exists after your transaction committed. So a report of a changed aggregate in that transaction finds no id yet. There is no instance to name there either.
Every row also says on which version of its process the workflow runs, in the column
PROCESS_VERSION. That is the version as your BPMS counts it: a number on Camunda 7 and Camunda 8,
or a version tag where the BPMS has only that. Both a delivery row and a start row carry it,
wherever your adapter names it. It stays empty where your BPMS has no versions.
An extension which wants the id asks for it, and gets the version with it, see extensions.
Applications which manage their database schema themselves need the column as VARCHAR(32). Fill
it with TASK_DELIVERY for the rows which are already there, and make it NOT NULL from then on.
In the same step OUTCOME becomes nullable, because a start reports no outcome. The index over the
open rows is rebuilt with RECORD_KIND in front of OUTCOME. PROCESS_VERSION is a nullable
VARCHAR(255) without an index. The changelog of io.vanillabp:vanillabp-schema does all of this,
and VanillaBP names what is missing at startup.
Until the workflow ends or until the retention period is over, whichever comes first. By default only the retention counts. The end of the workflow counts only if you switch it on:
vanillabp:
delivery:
release-on-workflow-end: true # default: false
workflow-modules:
loan-approval:
delivery:
release-on-workflow-end: false # this module keeps its recordsA start row is not part of any of this. It is kept for vanillabp.delivery.workflow-start-retention,
thirty days by default, counted from the start. The end of its workflow does not delete it. That is
on purpose. Changes to a workflow aggregate keep arriving after its workflow ended. They come from
your own @WorkflowEnded method and from whoever maintains the business data of a finished case,
and each of them may want to name the workflow. After the period, the id is no longer known. Such a
report then finds no id and is dropped without a word. 0 keeps the rows forever.
If a fixed period does not fit your case, tie the row to the aggregate instead:
vanillabp:
delivery:
workflow-start-retention: 90d # default: 30d
keep-workflow-start-while-aggregate-exists: true # default: falseWhen this is on, VanillaBP loads the workflow aggregate by its id before it deletes such a row. It
keeps the row as long as that aggregate exists. This is exact, where a period is only a guess. It
costs one read of your database per expired row, so it is off until you ask for it. VanillaBP looks
at a thousand rows per hour, and the rest waits for the next hour. Your persistence may not be able
to load an aggregate by its id, for example a custom AggregatePersistenceAware which does not
implement loadById. Then the row is kept and not deleted on a guess, and VanillaBP says so once.
Nothing of an ended workflow can be delivered again. So with this switch its records are deleted right away, in the transaction of the end notification. The retention period cannot know when a workflow ends. Seven days are too long for a busy application and too short for a task which really stays open longer. The deletion is limited to the workflow module, the BPMN process, the workflow aggregate and the moment the end was reported. So a second workflow on the same aggregate keeps the records it writes afterwards.
It is off by default because it has a cost. VanillaBP asks the BPMS to report the end of a workflow only where that end is used. So switching this on adds a listener or a worker to every deployed process of the module. Leaving it off is also the way to keep the records for support.
The retention still cleans up everything the end of a workflow cannot: workflows which are still running,
aggregates whose workflow never ends, and stores which do not implement the deletion. A store of your own shows that
it can delete by implementing TaskDeliveryLog.releaseRecordsOf(...). If it does not, and you switched the release
on, the start says so and the retention does the work. The two built-in stores delete by workflow module, BPMN
process and aggregate id. So a large table needs an index over those columns.
There are three things this does not cover, and they matter more than the feature itself:
-
A handler which threw leaves no record. The transaction rolled back and took the record with it, so the BPMS' retry runs the handler once more. That behaviour is unchanged. It is also the reason why a record is never written before the work it stands for.
-
Two deliveries of the same task may run at the SAME time. Both find no record and both run. The BPMS handed the task out again while your handler was still working, and a record can only speak about committed work. So everything a handler does outside its transaction, a mail for example, is still your business. The rule to base such decisions on the state of the workflow aggregate covers it. You do get told. The delivery which finds the record already written logs one WARNING which names the task, the workflow, the adapter and the delivery key. It is counted as
vanillabp.task.redeliveries.concurrent(see the metrics). Nothing is rolled back, because both handlers really ran. Some BPMS let you set how long a task stays locked. There, a lock which lasts longer than the handler stops the second delivery from being handed out at all. -
A BPMS which cannot name its deliveries keeps the old behaviour. VanillaBP needs an identity which stays the same across a redelivery. An adapter which has no such identity reports nothing. Camunda 7 on your application's datasource does not need one. It delivers tasks inside your transaction, so a redelivery there proves that nothing was committed. Here is what each adapter reports as that identity:
BPMS Identity of a delivery Camunda 7 none while the engine shares your datasource, and none is needed there. With a datasource of its own, the id of the current job. Its wiki says what that mode still leaves to you Camunda 8 the job key, which is stable across every redelivery of that job and unique within one cluster Process-Engine-API the task id the engine reports
Without a store, nothing is remembered either. Your aggregates may live in a persistence for which VanillaBP
brings no store. Then your application gets one WARNING at startup. It names both ways out: a TaskDeliveryLog
bean of your own, or the switch above. The application then behaves as it did before this feature existed.
Next to the workflow aggregate (matched by type, no annotation), these parameter annotations are bound on each call:
| Annotation | Type | Value |
|---|---|---|
@TaskParam("name") |
see below | a local variable mapped in the BPMN (input mapping) |
@TaskId |
String |
the BPMS-side ID of this task instance |
@TaskEvent |
TaskEvent.Event |
the event processed (CREATED on delivery, CANCELED when the open task's activity is canceled, see below) |
@MultiInstanceIndex("element") |
int |
the current index of the multi-instance iteration |
@MultiInstanceTotal("element") |
int |
the total number of iterations |
@MultiInstanceElement("element") |
any | the current element (alternatively via resolverBean) |
A @TaskParam reads what the BPMS GENERATED on this path through the model. That is the target of an input or
output mapping, or the result variable of a script or of a called decision. It is not a second way to read your
workflow aggregate, which your method already has. Most applications need none of it. But you can never rule it
out either, because a model may produce a value which the application has to see.
VanillaBP reads the names from your annotations while it wires your application, and tells the adapters. Some BPMS ship a payload of variables with every delivery. Such a BPMS then asks for exactly those variables and nothing else. On Camunda 8 the worker names them when it subscribes. On the Process-Engine-API adapter the task subscription does. Camunda 7 needs none of this. The engine runs in your JVM and reads a variable from its own database the moment your method uses it.
This has two results. First, you configure nothing. The list follows your code. To make a variable travel, you only add a parameter to a method, whether or not your BPMN model mentions the name. Second, the variable does not have to be declared in the model at all. A value the workflow was started with reaches your method just as well.
@MultiInstanceElement(resolverBean = MyResolver.class) names a resolver. It is a component of your application (a Spring bean, a CDI bean) which nothing injects. VanillaBP asks the container for it while the task runs. That is all you have to do, on both platforms. On Quarkus the build keeps such a bean even though nothing injects it. If the class is no bean at all, the build says so.
A method which declares a @TaskId parameter is an asynchronous task. It is not completed when the method returns. Complete it later with ProcessService#completeTask(...) and that ID. (why)
Declare the type your value has, and VanillaBP converts what the BPMS reported into it. A number reaches any other number type, as long as the value survives the conversion. If it would not survive, the task ends with a message, so the value never arrives wrong. A text from the BPMS reaches an enum as the name of its constant. It reaches a UUID, a java.time value, a java.util.Date or a java.util.TimeZone as the value which the text stands for. These are the same texts which your workflow aggregate is shared as. So a handler reads back what the aggregate wrote.
The list of types is a selection, not a promise about the JDK. If you miss a type, please open an issue. The section What a @TaskParam may be declared as of the migration adapter lists which types are carried, as which text each of them travels, and which ones are refused.
If nothing in that list fits, declare a String and read the text in your own code. A type of your own is refused. A BPMS carries plain values, and what one of your types means is for you to build.
Two declarations are named while your application starts, and you confirm once that you know what you are doing. The first is a type which, as an adapter reports, its BPMS hands back as something else. The second names no type at all. @TaskParam Object gets a BigDecimal from Camunda 7 and a Double from Camunda 8, so you have to cover it with tests. The key and the whole rule are on Which types may travel.
Business code completes an open asynchronous task with processService.completeTask(aggregate, taskId). It cancels the task with processService.cancelTask(aggregate, taskId, bpmnErrorCode). The error code sends the workflow through an error boundary event. The rules:
-
Call it inside a transaction, as with
startWorkflow. The aggregate is saved, and the real completion runs AFTER your commit (transactional outbox pattern). So a rollback takes the completion with it. This is the same on every BPMS, an embedded engine included. -
VanillaBP finds the BPMS which holds the task by itself. Usually it knows the answer without asking anybody. The record it wrote when your handler ran names the BPMS which delivered that task. So
completeTaskreads the record and goes there, see The record also says which BPMS holds the task. If no record answers, VanillaBP asks the prioritized adapters one after the other. The migration feature is built on this: an old and a new BPMS may both be configured, and the one which knows the task carries out the operation. Successful answers are cached, so later operations on the same workflow skip the search. If a BPMS is unreachable for a moment, the call retries for a short time and then fails. It does not try the next BPMS, because the unreachable one may be exactly the one which holds the task. (why) Where its BPMS cannot be asked at all, what an adapter ANSWERS can still be a guess. The migration feature lists which configurations those are, because that is where the guess decides something. -
Unknown task: if no BPMS knows the task ID, a
TaskNotFoundExceptionexplains the likely causes (wrong or outdated ID, workflow terminated). -
Already completed: the operation does nothing and logs a warning. So calling
completeTasktwice (for example after a retry) is safe. VanillaBP writes into the record that it completed the task. So the second call is answered from there, with the same warning and without asking a BPMS.
The word cancel needs a note here, because BPMN uses it for something else. cancelTask and cancelUserTask
complete the task by sending a BPMN error. So the workflow leaves the task through an error boundary event and
carries on from there. In BPMN, the cancel event belongs to a transaction subprocess and to nothing else.
VanillaBP touches neither of them, and each adapter's Deviations page says so in its own words. The word also
turns up in a third place,
WorkflowEnd.Kind.CANCELED. There it means a
workflow which ended without reaching an end event.
@TaskEvent parameters deliver life cycle events. Every delivery of the task itself is CREATED. The workflow may CANCEL the activity of an open task, by an interrupting boundary event or by process termination. Then methods which subscribe to CANCELED (or ALL) are called once more with that event. Methods without a @TaskEvent parameter never see cancellations.
When the event arrives. An embedded engine such as Camunda 7 reports the cancellation of each element itself, from the transaction which cancels it. So your method is called the moment the workflow moves on. A remote BPMS usually cannot report that, so VanillaBP works the cancellation out itself. It does so at two moments:
-
The workflow ended. VanillaBP knows every task of that workflow for which your application still owes an answer. A workflow which ended needs no more answers. So all of these tasks reach your method as
CANCELED, and then your@WorkflowEndedmethod runs. The kind of end does not matter. For example, a terminate end event ends a Camunda 8 instance as completed, and it takes an open task with it. - The next job of that workflow arrives. Whenever the BPMS hands your application a job, VanillaBP asks it about the other tasks it believes are open in the same workflow. It reports the ones the engine no longer has. It checks ten per wake-up, oldest first. The rest wait for the next one.
This misses one case. After the boundary event, a workflow may walk into a timer or a wait for a message. That produces no job, so nothing wakes your application up, and the cancellation waits. It arrives at the next job of that workflow, at the end of the workflow, or at your next call which names the task, whichever comes first.
Whoever reports the cancellation, VanillaBP writes into the record of that task that it is over. So nothing reports it twice, and nothing keeps showing it as waiting.
The check is on by default. It only affects methods which asked for the event. Switch it off if you do not want these new calls:
vanillabp:
delivery:
check-open-tasks-on-delivery: false # default: true
max-open-tasks-checked: 10 # the default: probes per job of one workflowYou can override both per workflow module, per workflow and per task, the same way as max-task-age. A method may read a @TaskParam or a multi-instance value. In a cancellation which VanillaBP worked out itself, it gets those values as null, because the element is gone by then. At startup your application is told which of its methods this affects.
BPMN is more precise here than anything on the market. It gives an activity eleven states. Ready and active are at the start, completing and completed at the end, compensating and compensated are for undoing work. A few more cover the ways an activity fails or is taken away. The specification asks a tool which claims execution conformance to support that life cycle.
No BPMS we adapt reports it. Camunda 7 knows three moments where a listener can sit: start, end and
take. Camunda 8 knows five on a user task listener: creating, assigning, updating, completing
and canceling. The two sets differ, and neither of them is the eleven states.
So TaskEvent.Event has two values and keeps them. A wider enum would need one meaning on every BPMS,
and there is no such meaning. A value which no BPMS fires never arrives. A value which two BPMS fire at
different moments is worse than no value at all. CREATED is the delivery of the task, at whatever moment
the model picked for it. CANCELED means the element goes away. The model decides which moment a method
gets, with one listener per moment and one method per listener.
(why)
(and why on Camunda 8)
VanillaBP does keep a state for the delivery. That is a different thing from the state of an element in the model. The record written per delivery says what was processed and what is over. A task nobody can answer for any more is closed in it. We can promise that state, and it means the same on every BPMS.
A task whose handler declares @TaskId is not completed when the method returns. For the BPMS it stays open until
your code calls completeTask or cancelTask. That is the intended shape for anything which waits for the outside
world. It is also the one shape in which a workflow can wait forever without anybody noticing. The BPMS keeps the
task. VanillaBP keeps answering its redeliveries from the record it wrote when the handler ran. And no part of that
ever asks whether the completion is still coming.
So VanillaBP knows how old an open task is. The record of the delivery holds the moment the handler ran. The time
between that moment and now is how long your application has owed the task its completion.
vanillabp.delivery.max-task-age says how long that may be:
vanillabp:
delivery:
max-task-age: P30D # the defaultThe default is thirty days, and it only reports. An asynchronous task which waits for a person or a partner may
rightly run for weeks. A default which failed such a task would be worse than the leak it looks for. A default which
never fires would leave the leak invisible. The report is one WARNING per task. It names the workflow module, the
process, the workflow aggregate and the age. It is written once per task, not every time the BPMS asks about the
task again. Set the value to 0 if your tasks have no upper limit and you would rather not be told.
The window right after an upgrade, and how you see it close. The record makes this work. A task which was already open before your application first ran has no record. Its next delivery runs your handler a second time, exactly as VanillaBP 1 did for every delivery. Nothing can be repaired there. A record would have to claim that your handler ran, but a task the BPMS holds may just as well belong to a handler which crashed halfway. So keep the guards your handlers had in version 1 until the window is closed. The start tells you when that is. If the BPMS holds open tasks of which VanillaBP has no record, the start says so once per BPMN process, with the number. The number falls to zero as each of those tasks is delivered once.
One age is a lower limit and not the truth, and the message says so. A task which was already open when you stopped the application for the upgrade has no record. VanillaBP writes one at the first redelivery after the upgrade. So the time it can measure starts there, not when the task was really created. Where the adapter can tell that the workflow was already running before the version you deployed, the report says "at least" and why. The number is honest but not exact. It errs on the useful side: a lower limit above the maximum is still reported, because the real age can only be larger.
You may narrow the setting where the general value does not fit. The most specific value wins:
vanillabp:
delivery:
max-task-age: P30D
workflow-modules:
loan-approval:
delivery:
max-task-age: P90D # this module waits for banks
workflows:
LoanApproval:
tasks:
awaitSignature:
delivery:
max-task-age: P180D # and this one task waits for a signatureWhat happens beyond the report is up to the BPMS, because only some of them have a better place for it than a log. Some BPMS hold the task as something an operator already watches. There the adapter can turn an overdue task into a finding and offers a setting for it. Where the BPMS has no such place, the report is all that happens. Which of the two your BPMS is, and what to configure, is on your adapter's wiki.
On purpose, there is no callback into your application about an open task. An application which knows that the task
is no longer needed has cancelTask, which works on every BPMS. An application which lost track of the task could
not answer the question "is this still alive?" truthfully anyway.
The record which answers those redeliveries is kept while they keep coming. Each redelivery moves the moment the
record was last seen. vanillabp.delivery.retention counts from there. So the period only starts once nobody hands
the task out any more. A task which is open for months keeps the record which answers it. The moment the handler ran
is a second timestamp, which never moves. The age above is measured from that one.
A refresh is not a write per redelivery. The background run which also deletes expired records collects the keys and writes them in one go. If a process dies in between, this costs at most one round of refreshes. A record is only deleted after a whole retention period without a single refresh.
You may also connect @WorkflowTask methods to BPMN USER tasks, as optional notification handlers. The method is called when the task is CREATED, for example to send an email or to fill a task list of your own. It gets the task's ID through @TaskId. You need that ID to complete or cancel the task later:
@WorkflowTask(taskDefinition = "approveRequest")
public void approveRequestCreated(final Ride ride, @TaskId final String taskId,
@TaskEvent final TaskEvent.Event event) {
// CREATED: notify the assignee; CANCELED: clean up the own task list
}- Task definition by convention: which BPMN attribute of a user task carries the task definition depends on the BPMS (usually the form reference), see your adapter's wiki.
-
A method or a line: like every other task, a user task needs a
@WorkflowTaskmethod. Or it needs the configuration line which says that something else serves it, for example a form in a task list such as the VanillaBP Business Cockpit, see a task something else serves. Without either, the start ends, as it did in version 1. -
Never completes on return: business code completes the task with
processService.completeUserTask(aggregate, taskId). It cancels it by BPMN error withcancelUserTask(aggregate, taskId, errorCode). The rules for completing asynchronous tasks apply: a transaction is required, the BPMS is found by asking,TaskNotFoundException, and nothing happens if the task is already completed. -
Do not throw
TaskExceptionin notification handlers. At creation or cancellation time there is nothing to complete by BPMN error. UsecancelUserTaskinstead.
A call activity may call a process which works on the same workflow aggregate. That process continues the same business case, so it gets the same aggregate. VanillaBP makes sure of that on every BPMS, whatever the engine does on its own. (Camunda 7 needs help there, see its adapter's wiki.) A called process with an aggregate of ITS own is a different story. It gets its own identity. That is the "reuse" case of the SPI (service provider interface) documentation.
One workflow aggregate has one ProcessService. So startWorkflow starts exactly one BPMN process: the bpmnProcess of its workflow service. Further processes which work on the same aggregate belong in secondaryBpmnProcesses of that class. The classic case is a process called by a call activity. Their handlers may live in classes of their own, as long as those classes declare the same bpmnProcess. Two classes of one aggregate may declare DIFFERENT processes. This is reported while the application starts (Quarkus: while it is built). It is not decided by the order in which the classes were found. The same holds when classes of two aggregates declare one process, see a process belongs to one workflow aggregate.
A task of a called process belongs to the called process. It does not belong to the process at the top, which the aggregate was started with. VanillaBP finds its @WorkflowTask method under the id of the called process. A version on the method is matched against the versions of the called process too. A version on the @BpmnProcess in secondaryBpmnProcesses is the range which the method inherits there.
@WorkflowService is @Inherited. So a class which extends an annotated class is a workflow service too. VanillaBP works with the subclass you wrote. Its own handler methods and the ones it inherited both serve the process. If the annotation names no BPMN process id, the simple name of the subclass is used. On Quarkus, the JAR the subclass sits in decides its workflow module. Both platforms read it that way. (why)
Put the declaration on an abstract base class when several classes should share it. Take a base class which names no bpmnProcess, and two subclasses of it. Each subclass would name its process after itself, which makes two processes for one aggregate. So if they should serve one process, name the bpmnProcess on the base class. And a class which inherits the declaration has to be a bean of its own, because VanillaBP asks the container for an instance of exactly that class.
On every start VanillaBP checks both directions. Every executable BPMN task has a matching @WorkflowTask method (per BPMN process), or it is marked as served by something else. And every method matches a task of at least one BPMN process of its workflow module (a class may serve several processes through secondaryBpmnProcesses). A defect stops the start with a message. It names the task, the process, the workflow module and the fix, for example the exact annotation to add. The check covers the processes which a @WorkflowService class claims. A process which none of them claims ends the start, unless you mark it as somebody else's, see below. For adapters which are not first in priority, the deployment-failure: warn policy applies (see the platform integration pages).
The start also fails when a task has to stay open but its model completes it as soon as your method returns. Which way of modelling that is depends on the BPMS. So the message names the attribute to change, and your adapter's wiki describes the convention. The point is to learn this while the application starts. Otherwise a task which cannot stay open would show up as an incident on a live workflow.
Within one workflow module, a BPMN process belongs to exactly one workflow aggregate. Classes of different aggregates must not declare the same process, neither as bpmnProcess nor in secondaryBpmnProcesses. If they do, the start stops (Quarkus: the build stops). The message names both classes, both aggregates, and how each of them declares the process.
The reason is the order of the classes. If both were allowed, the class found first would decide two things: which aggregate the tasks of the process load, and whether the process is a workflow of its own or a step of another one. A step has no start and no end of its own (see Starting workflows). So this order would decide whether your @WorkflowEnded method runs. Version 1 logged a warning here and kept the class it found first.
So decide what the process is:
-
A workflow of its own. Only classes of its own aggregate declare it. Remove it from
secondaryBpmnProcessesof the other class. A call activity in the other workflow may still call it. But then the call is a matter of your BPMS. The mappings of the call activity in your BPMS set up the data which the called workflow gets, not VanillaBP. Two separate workflows are usually better started and kept in step by messages (see Message correlation). Then the contract between them lives in your business code, not in the BPMN. -
A step of another workflow. Only the class of that workflow lists it in
secondaryBpmnProcesses. No class of another aggregate declares it. - A step of two workflows. That is not possible, because a step works on the aggregate of its workflow. Give the second workflow a copy of the process with a BPMN process id of its own. Or make the process a workflow of its own.
Classes of one aggregate may still share a process. Their handlers are merged. The same id in another workflow module is a different process. (why)
Every task of a process your application claims needs a @WorkflowTask method. That includes user tasks, listeners and any other task your BPMS hands over. Sometimes that is not what you want. A person may work on a user task in a task list, and nothing in your code has anything to do with it. Or a worker you run next to the application answers a job. Then you say so, and the start lets the task pass:
vanillabp:
workflow-modules:
loan-approval:
workflows:
LoanApproval:
tasks:
Activity_ReviewByHand: # the element id
implemented-externally: true
audit-log: # or the task definition
implemented-externally: trueYou name a task by its element id or by its task definition. Both work. If both are written, the element id wins. The element id of a user task also covers every listener on it which no method serves. A listener with a method keeps its method. If only the listener is served elsewhere, name the listener by its own task definition. The element id would mark the user task as well.
When a start ends because nothing serves a task, the message gives you the line to copy. Some task definitions contain a dot or a colon, for example a Camunda 8 job type like io.camunda:http-json:1. Such a name cannot stand in a property key as it is. Spring Boot puts such a key in brackets, Quarkus in quotes. In a .properties file a colon needs a backslash on both platforms. So the message writes such a line once per platform:
# Spring Boot
vanillabp.workflow-modules.loan-approval.workflows.LoanApproval.tasks[io.camunda\:http-json\:1].implemented-externally=true
# Quarkus
vanillabp.workflow-modules.loan-approval.workflows.LoanApproval.tasks."io.camunda\:http-json\:1".implemented-externally=trueMore rules:
- A method and a line for the same task end the start. The method would answer the task, and so would whatever the line stands for. Remove one of them.
-
A line above the task covers only the tasks without a method. You may write the line for the workflow, the workflow module or the application. It then marks every task below it which has no method. A task with a method keeps it, and nothing is said about it. So one line is enough for a workflow whose user tasks are worked on in a task list, and its service tasks keep their methods. A line at the task which says
falsetakes a single task back out. - A line which no model needs is a warning. Once the workflow module is deployed, VanillaBP compares every such line with the tasks of the models it deployed. It names the lines which match nothing. So a list of names cannot go out of date without anybody noticing. A line for a workflow or a workflow module which covers no task without a method is named as well. Versions your BPMS still holds are not checked, and neither are their user tasks, because nobody can change those models any more.
-
The line may name one adapter. Two adapters of a BPMS migration may disagree about a task, because one BPMS has a connector for it and the other has not. Then write the line below
adapters.<id>of the task. The key knows every level a setting may be written at, see the levels a setting is written at. A line above the task covers only the tasks without a method.
A BPMN file is deployed as a whole. So every executable process it declares reaches the BPMS, whether or not your application serves it. That is normal. A modeller draws a called process next to the calling one. And a process which moved out of an application often leaves its model behind in the file.
The start cannot tell such a process from a workflow service you forgot. So it ends, and names each process which no @WorkflowService class claims, together with its file. Version 1 ended the start here as well. You answer in one of two ways. If your application serves the process, write the @WorkflowService class for it. If somebody else serves it, say so:
vanillabp.workflow-modules.<module>.workflows.<process>.implemented-externally=trueThe line may name one adapter, below adapters.<id> of the workflow. Then it marks the process for that adapter only. A line at the workflow module or for the whole application does not mark a process.
A marked process goes to the BPMS with its file, and VanillaBP leaves it alone. The adapter writes nothing into its model except what the whole file needs, for example the prefix of use-prefix. No worker, subscription or listener serves it. No check of the adapter ends the start or warns about it. A workflow of that process stops at its first task until whatever serves it takes the task. The process still has to meet what its BPMS demands of an executable process. On Camunda 8, for example, the cluster rejects the whole file if a message carries no correlation key. VanillaBP writes that key only for a process your application claims.
A process which somebody else deployed into the same BPMS, without your files, is not touched at all.
A process called by a call activity counts as yours when a workflow service declares it, for example in secondaryBpmnProcesses of the workflow service of the caller.
A forgotten @WorkflowService also looks like this refusal. A class which is no bean leaves its process claimed by nobody. That happens when its profile is not active or nothing scanned its package. The Spring Boot and Quarkus pages describe what stops a workflow service from becoming a bean.
On Spring Boot the refusal names the class itself. A start which refuses a process reads the class files of the workflow module. It names every class which carries @WorkflowService for that process without being a bean. It also names the ways to make it one: a bean-defining annotation such as @Service, a @Bean method, or the auto-configuration of the workflow module the class belongs to. The start cannot tell this case from another one, so it names that too: the class may belong to a profile which is not active, or to another application on the same classpath. A healthy start reads no class files at all.
On Quarkus the same case never reaches a start. The build knows its set of beans. So a class which carries @WorkflowService and which no bean satisfies fails the build, and the build names the class.
Two ways of writing a handler method make it invisible, and both look right in your editor. First, VanillaBP reads the public methods of your workflow service class. A protected, package private or private @WorkflowTask method is not among them:
@WorkflowTask
protected void processPayment(final Ride ride) { // never wiredSecond, Java never inherits a method annotation. So a method which overrides an annotated one removes the handler, unless it repeats the annotation:
public abstract class RideHandlers {
@WorkflowTask
public void processPayment(final Ride ride) { ... }
}
public class TaxiRide extends RideHandlers {
@Override
public void processPayment(final Ride ride) { ... } // the handler is gone
}VanillaBP names both cases while the application starts, with the method and the class which declares it. Otherwise the wiring validation would ask you for a method which you can see in your own source. VanillaBP will not call such a method anyway. Reflection could do it, but then the visibility of your methods would no longer be your decision. Make the method public, or repeat the annotation on the override.
Some things depend on the BPMS: how a BPMN task names its task definition, how tasks are delivered and retried, and whether the BPMS reports a cancellation itself or VanillaBP has to work it out. Each adapter documents this on its own Configuration page, and what it cannot deliver on its Deviations page:
- Camunda 7: Learn more...
- Camunda 8: Learn more...
- bpm-crafters Process-Engine-API: Learn more...
A BPMN model changes over time, and older workflows may not be able to follow a change. @WorkflowTask(version = ...)
tells VanillaBP which versions of the process a method serves. So both versions can run side by side while the old
workflows finish (why):
@WorkflowTask(taskDefinition = "chargeCreditCard", version = "<10")
public void chargeCreditCardUpToTen(final Ride ride) { ... }
@WorkflowTask(taskDefinition = "chargeCreditCard", version = ">=10")
public void chargeCreditCard(final Ride ride) { ... }The version is the version of the deployed process definition, as the BPMS counts it. It is not a version your application makes up. Camunda 7 and Camunda 8 both count upwards in whole numbers per BPMN process id. A workflow keeps running on the version it was started with. Instead of that number, a boundary may name a version tag of the model. That is usually where a release name lives. The name of that attribute depends on the BPMS. It is documented on your adapter's Configuration page:
| Specification | Serves |
|---|---|
missing or *
|
every version |
3 |
version 3 |
release-2024 |
every version tagged release-2024
|
1-3, v1.0..v2.0
|
a range, both boundaries included |
<3, <=3, >3, >=3
|
open ended, without or with the boundary |
Ranges accept .. as well as - as their separator. A boundary which names a tag containing a - has to use ...
Otherwise nobody could tell a range from a tag. "Greater" and "less" mean the order of deployment. For a BPMS which
counts versions upwards, that is the numeric order.
Several methods may serve one BPMN task, as long as their versions do not overlap. Overlapping specifications are a
mistake. VanillaBP reports it when the application starts and names both methods. If no method serves the version of
a delivered task, the delivery fails with a message which names that version. The same attribute exists on
@WorkflowStartedByBpms and @WorkflowEnded and means the same there.
When your application starts, it deploys its model, and the BPMS gives that model a version. Every task of that model needs a method whose range covers this version. If no method of a task covers it, the start ends:
Version '1' of BPMN process 'loan_approval' (workflow module 'loan-approval') is the version adapter 'camunda7'
deployed during this start. Some of its elements are served by NO method of this application in this version:
- task 'ServiceTask_AssessRisk' (task definition 'assessRisk'): method
'blueprint.workflowmodule.loanapproval.WorkflowTaskHandler#assessRiskAutomatically' (version '>1'), which does
not cover version '1'.
Every workflow started on this version would stop at such a task with an incident.
Do one of these:
- widen the version range of the method so it covers version '1', e.g. version = ">=1".
- add a method with the same annotation for version '1'.
- deploy the model the method serves.
An extension can ask for the same check for its own annotation, for example for a details provider of the Business Cockpit. Then the message also lists an element of that extension, with the annotation, the method, the extension and the range, and one more sentence says what the gap means for the extension:
- element 'Activity_Review' (also matched by 'reviewForm'): @UserTaskDetailsProvider method
'blueprint.LoanApprovalDetails#review' of extension 'business-cockpit' (version '>1'), which does not cover
version '1'.
For @UserTaskDetailsProvider of extension 'business-cockpit': It finds no method to call for such an element and goes
on without one.
The fix is the same as for a task. An element which no method of the extension serves at all is not part of the check.
Without this check the start would go on, and every workflow would stop at that task. The check that every task has a method runs before the deployment, when the version is not known yet. So the ranges are checked right after the deployment, against the version which each adapter's BPMS reported.
What counts is always the version which this start deployed. That is also true during a rolling deployment, when another node has already deployed a newer model. A task which somebody else serves (see a task something else serves) needs no method here either. A method which serves no version at all is a different finding, and only a warning, see the versions your BPMS still holds. (why)
A change may be so big that a whole class of handlers belongs to the old model. Then say it once on the process,
not on every method. @BpmnProcess(version = ...) is the fallback for all three method annotations. A method which
names no version of its own serves the range of the process it was declared with. (why)
@WorkflowService(
workflowAggregateClass = Ride.class,
bpmnProcess = @BpmnProcess(bpmnProcessId = "taxi_ride", version = "<10"))
public class TaxiRideUpToTen {
@WorkflowTask // serves versions below 10
public void chargeCreditCard(final Ride ride) { ... }
@WorkflowTask(version = "5-7") // its own range, which the class does not narrow
public void issueReceipt(final Ride ride) { ... }
}A method which names its own range keeps it exactly as written, even where it reaches outside the range of the
class. The two ranges are not intersected. A range you cannot read from the annotation in front of you is worse than
a repeated one. The process a delivery came from decides which declaration applies. A class declares one
bpmnProcess and any number of secondaryBpmnProcesses, each with a version of its own. One method may serve
elements of both.
Two classes may declare the same process. That is what this is for: one class per generation. They start as long as
their ranges do not overlap. Overlapping ranges end the start, and the message names both classes and both
declarations. This does not change which process startWorkflow starts. That is the primary declaration, and
there is still exactly one ProcessService per workflow aggregate.
There is one surprise. A method which inherits a range is restricted like any other. A BPMS which reports no version cannot reach it, just as it cannot reach a method which names a range itself. To serve such a delivery, you need a method whose class names no version either. The message of a failed delivery says which declaration the range came from, because the method it names carries no attribute at all.
A specification made of numbers is compared with the version which the BPMS reports with the task, so it costs nothing. A specification which names a version TAG needs a BPMS which can say which version carries which tag. VanillaBP asks once per process while the application starts and caches the answer. It asks after the deployment, so a tag deployed by this very start is included. It asks again only for a version it has never seen. That happens during a rolling deployment, when another node has already deployed a newer version.
| BPMS | Version | Version tags |
|---|---|---|
| Camunda 7 | yes, with every task, start and end | yes |
| Camunda 8 | yes, with every job | yes, read from the cluster |
| bpm-crafters Process-Engine-API | no | only the tag of the current task, where the engine behind the API supplies it |
None of this affects an application whose methods carry no version. The default * serves every version, reported
or not. A BPMS which reports NO version reaches only methods without a version. A method which names versions is
never used there. If every method of a task names versions, the delivery fails with a message which says so. Nobody
can answer whether an unreported version lies within 1-3. Deciding it by the order in which reflection happens to
list the methods could differ between two starts of the same application. An adapter which cannot report a version
is still a complete adapter. Only the combination with version ranges does not work.
A BPMS which keeps no catalog of its versions says so while your application starts. The start then names the
methods which a delivery of that BPMS can never reach. On the Process-Engine-API, that is every method whose
version is a number or a range. A delivery carries only the tag of the current task. So version = "release-2024"
still works, and version = ">2" never does. You get one warning per BPMN process which names the methods, and your
application keeps running. A method named there can be the right one on another BPMS of your prioritized-adapters,
and it runs there.
Your BPMS keeps every version of a process it was ever given, and workflows keep running on them. Your application only brings its newest model. So on every start VanillaBP looks at the versions which are still deployed. It reads the models of the older ones and tells you which of their task definitions no method of yours serves any more (why):
WARN Version '1' of BPMN process 'TaxiRide' (workflow module 'taxi-ride') is still deployed at adapter
'camunda7' and its task definition(s) 'chargeCreditCard' are served by NO @WorkflowTask method of this
application, no workflow runs on it right now. Add a @WorkflowTask method whose version range covers
version '1', or declare that version obsolete by adding e.g. '1' to
'vanillabp.adapters.camunda7.outfaded-versions'.
If workflows still run on that version, the same finding is reported as an error, with their number. Each of them will fail with an incident at its next task which nobody serves. A method kept for an older version needs no task in your current model. That is exactly what its version range says, and the start does not complain about it.
The same check covers the start events which your BPMS fires on its own. You may declare a BPMN process id without
deploying a model for it. Then this start has no model to check a @WorkflowStartedByBpms method against. So
VanillaBP asks your BPMS for the start events of the versions it still holds under that id. Two cases are named
while the application starts, instead of never: a method which names a start event that none of these versions has,
and a declaration whose held versions never start on their own. Both are warnings which name the versions they were
read from, because nobody can change those models any more. If one of them cannot be read, nothing is said at all.
A method whose version range matches no version your BPMS holds is reported too. That holds for all three annotations which carry the attribute. Such a method never runs. The message lists the versions the BPMS holds ("held: 1, 2, 3"). So a typo, a version deleted from the BPMS, and code shipped before its model all become visible at the start, instead of never. "Your BPMS holds" counts every process the method is registered for. That is what makes the next section work.
While VanillaBP reads those models, it asks two more things about them. Both answers go into the block in which a start says about itself, not into a paragraph of their own.
A held version may declare a message name or a signal name which no model of this deployment declares any more. Renaming a message leaves this behind. The workflows on that version wait at their event for something which nothing sends. Nothing else could tell you, because the name lives in a model your BPMS holds and in no file of your application. The line names the name, the version and how many workflows are on it. It tells you to look, not what to write. A rename whose old workflows you finished by hand looks exactly like a rename which nobody finished. A version without workflows says nothing, because nothing waits there.
A held version may also iterate without naming the value of each round, while a method which serves that version
reads its @MultiInstanceElement item. When a model is deployed, your adapter refuses that pairing. The parameter
would be null, and nothing would say why. But a version your BPMS only still holds is never deployed again, so
nobody asked, and your methods serve it all the same. The way out is in the code. Narrow the version range of the
method, and add a method for the old version which reads only the index and the total. If your adapter does not read
the shape of a held model, nothing is said at all.
Renaming the process id is the one change to a model which reaches into your BPMS. The old id stays there with all its versions and with the workflows still running on them. Your application brings only the new name. Declare the old id as a secondary process of the same class. Then your methods keep serving those workflows, and everything on this page applies to both ids. Renaming a BPMN process is the recipe. It also covers the one trap: each process id is counted from 1 on its own, so the versions of the two ids are different models with the same numbers.
Sooner or later you do not want to serve an old version any more. Name it, and VanillaBP stops asking for it:
vanillabp:
workflow-modules:
taxi-ride:
workflows:
TaxiRide:
adapters:
camunda7:
outfaded-versions:
- "<4" # every version older than 4The specifications use the grammar of the version attribute above. Several of them together mean any of them.
Like every other adapter key, this key belongs to one adapter. It can sit at the workflow, the workflow module or
the adapter. That it belongs to ONE adapter matters. Every BPMS counts its own versions. So during a
BPMS migration, the old BPMS fades its versions out while the new one runs the current model.
VanillaBP insists on two things:
- The version your start deploys can never be faded out. A specification which covers it is a configuration error, and the application does not start. Otherwise the process would be left without a served version.
-
Workflows still running on a faded-out version are reported as an error, with their number. Set
vanillabp.adapters.<id>.outfaded-versions-in-use: FAILif you would rather have the application not start at all than have those workflows walk into an incident. The default isLOG. You decide which is worse: living with old workflows, or an application which refuses to start. VanillaBP does not decide that for you.
A BPMS may not be able to answer, because it counts no versions or no longer holds the version in question. Then VanillaBP says so once per process and carries on. The check is off there, and fading out still applies.
You may write an adapter setting for a single task. The usual case is the job timeout of a remote BPMS. The adapter sets one timeout for every task. A task which must not wait that long before the BPMS hands it out again gets its own.
vanillabp:
adapters:
my-bpms:
job-timeout: PT5M # adapter level (base)
workflow-modules:
loan-approval:
workflows:
RiskAssessment:
tasks:
scoreApplicant:
adapters:
my-bpms:
job-timeout: PT30S # task level (most specific)The task is the most specific place for such a key. The levels above it are the same for every setting, and one page describes them: the levels a setting is written at. See your adapter's wiki for its available keys.
