Repository navigation
Spring Boot integration
This section explains the minimum you need to know to get VanillaBP running in a Spring Boot project: structure and dependencies, no theory.
-
Choose an appropriate project structure
VanillaBP works for monolithic applications and for microservices. It scales from small projects with one module to large Maven or Gradle builds with many modules. Your setup decides which dependencies you need. -
Add the required dependencies to the correct Maven/Gradle modules
VanillaBP has a small set of dependencies, each with a clear purpose. Add each of them where it belongs. -
Configure the transaction outbox
Only needed for adapters of a remote BPMS (business process management system). VanillaBP uses a transaction outbox to start workflows reliably. It works without any setup, and you can tune it with thevanillabp.outbox.*properties. -
What VanillaBP remembers about delivered tasks
The delivery log, which answers a repeated delivery instead of running your handler again, and the little it needs from you. -
Creating the tables with Liquibase or Flyway
The four tables VanillaBP owns, written out for a project which manages its schema itself, and what the same switch means on MongoDB. -
Configure the adapters
Each BPMS you use gets a section belowvanillabp.adapters. Optional settings decide what happens when a deployment fails, and how to cope with a BPMS which is not available for a while.
VanillaBP supports business process applications built as a monolith or as microservices. Both work with a single module as well as with large Maven or Gradle projects with many modules.
If you use several modules, you usually keep the workflow modules apart from the main Spring Boot application. The workflow modules page has concrete examples.
To decouple them further, each workflow module can bring its own configuration files (YAML and/or .properties), named after the workflow module ID. VanillaBP loads these files automatically and merges them into the Spring Environment as defaults. Whatever the application configures wins over them, application.yaml and application-prod.yaml included. Profile-specific files (e.g. loan-approval-prod.yaml) work as well. In projects with several modules, you can put the configuration files into a subdirectory named after the workflow module ID (e.g. loan-approval/loan-approval.yaml). That is the same convention as for all other resources of a workflow module.
The main rule: put everything a workflow module needs into its own configuration files. The application.yaml should only hold values which really concern the whole application, such as database settings, server configuration or the definitions of the VanillaBP adapters. Properties of one module, like endpoint URLs, timeouts or feature flags, belong in the module's own files. So the workflow module more and more becomes a self-contained application inside the application. See Configuration for the details and for which file wins.
Depending on your project structure, add these dependencies.
-
VanillaBP adapter dependencies:
For each BPMS your application uses, add exactly one matching VanillaBP adapter. Add it to the main application module (with several modules) or to the single module which builds the Spring Boot application. The adapter dependency brings the VanillaBP Spring Boot integration along, so you need no other VanillaBP dependency. See Available BPMS adapters for the list of adapters and their documentation. -
io.vanillabp:vanillabp-spring-boot-support:
This dependency contains the VanillaBP APIs you need to compile. Add it to pure workflow modules when you have several modules. It keeps workflow modules independent of the concrete BPMS. The APIs of a BPMS are out of reach on purpose, so you cannot break the hexagonal architecture by accident. The module's README lists the APIs it includes. It also brings the integration SPI along. These are the interfaces your application may implement where VanillaBP needs something only your application knows, most of all aggregate persistence.
io.vanillabp:vanillabp-bom manages the versions of all VanillaBP artifacts an application uses. Import it once in the parent POM and leave out the <version> of every VanillaBP dependency:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.vanillabp</groupId>
<artifactId>vanillabp-bom</artifactId>
<version>2.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>For Gradle use implementation platform("io.vanillabp:vanillabp-bom:2.0.0").
Maven Central lists which patch releases of the 2.0 line exist.
Two things are worth knowing about the BOM:
- BPMS adapters are not part of the BOM. Each adapter is released independently of the platform integration, so its dependency has its own version (see Available BPMS adapters).
- The BOM must never be older than your adapters. A version which your build manages always wins over the version an adapter needs transitively. Maven does this without a word, even if it means a downgrade. The build stays green, but the application would fail at runtime. So adapters check the platform version at startup. If it is too old, they stop with a message which names the artifacts to raise. Raising the BOM to the reported version fixes it.
Adapters which connect to a remote BPMS (e.g. a SaaS offering) cannot take part in the local database transaction of your application. This could lead to inconsistencies, for example a workflow started in the BPMS while its workflow aggregate was never stored. To avoid that, VanillaBP starts such workflows in two phases. It plans the second phase through a transaction outbox. That is an entry written in the same local transaction as the workflow aggregate. VanillaBP processes it reliably after the commit, even if the application crashes and restarts in between.
Phase two is a moment, not a kind of call. Phase one is your transaction, and phase two is everything which runs after its commit. A call to a BPMS is one kind of work in phase two. An operation which an extension registered for itself is another. Both are written into the same outbox, in the same transaction, and both run after it committed.
The embedded engine Camunda 7 takes the same path, although it could join your transaction. The reason: an operation which loses a concurrency conflict cannot be repeated inside the caller's transaction. Repeating only the engine part would move the workflow on while your data rolls back. So an application on Camunda 7 needs an outbox as well (see the adapter's wiki).
This works without any setup:
-
JPA: entries are stored in the table
VANILLABP_PHASE_TWO_OUTBOX. If an entry carries a payload, the payload goes intoVANILLABP_PHASE_TWO_OUTBOX_PAYLOAD. The tableVANILLABP_HOUSEKEEPINGrecords which node removes the old rows of a store tonight. All three tables are created automatically, and all three belong toio.vanillabp:vanillabp-schema. A Quarkus application runs the same store, written once for both platforms. A payload is deleted when its entry is dispatched. It is deleted again together with the entry whenvanillabp.outbox.retentionhas passed. An entry which still waits, or which is blocked, keeps its payload as long as it is in the table. The housekeeping deletes by age only payloads which no entry names any more. Such payloads are left behind by a write which never committed. -
MongoDB: entries are stored in the collection
vanillabp-phase-two-outbox. If an entry carries a payload, the payload goes intovanillabp-phase-two-outbox-payloads. Both collections belong to VanillaBP. MongoDB creates a collection with the first write, so neither has to exist before.vanillabp.outbox.create-schemadecides whether VanillaBP creates the indexes for reading them at startup. A payload is deleted when its entry is dispatched. It is deleted again together with the entry whenvanillabp.outbox.retentionhas passed. An entry which still waits, or which is blocked, keeps its payload as long as it is in the collection. The housekeeping deletes by age only payloads which no entry names any more. Such payloads are left behind by a write outside a MongoDB transaction, when that transaction does not commit. A backup should cover both collections, because an entry restored without its bytes cannot be dispatched. For transactional behavior, MongoDB has to run as a replica set, and you have to define aMongoTransactionManagerbean. Spring Boot does not configure one for you. Without it the startup ends with a message which names it, because an application whose aggregates no transaction covers cannot start a workflow at all.
The outbox is chosen per workflow aggregate. The entry always goes with the transaction which stores the aggregate. An application may use more than one kind of persistence (e.g. a JPA workflow module and a MongoDB workflow module during a migration). Then both defaults are active at the same time. Each aggregate is served by the outbox which matches its persistence, detected from the aggregate's Spring Data repository. VanillaBP checks at startup that a needed outbox is available. If the adapter with the first priority for a process needs the two-phase start and no outbox can be found, the application does not boot. The message names the ways to fix it.
You can tune the outbox with these properties (defaults shown):
vanillabp:
outbox:
# the longest the background poller sleeps while nothing is due. It is a
# cap and not a rhythm: the poller asks the store when its earliest
# unfinished entry is due and sleeps until exactly that moment. After a
# commit the entry is dispatched at once, except while the application is
# still starting (see "What the stores do differently"). Raise it and an
# application waiting in a timer stops asking its database at all - see
# "How late an entry can be dispatched"
poll-interval: PT10S
# the distance to the FIRST retry after a failed attempt; every further
# attempt doubles it until max-attempt-frequency is reached
attempt-frequency: PT30S
# the longest distance the growing backoff reaches, so a BPMS which comes
# back is noticed within it however long it was away
max-attempt-frequency: PT5M
# after how many failed attempts an entry is blocked (manual cleanup
# required); fifty of them span an outage of about four hours
block-after-attempts: 50
# how long an entry may wait for a BPMS which does not report its
# workflow yet, counted from the moment it was written; such a wait uses
# no attempts. Not set by default, which means the time the attempts above
# take, about four hours
# wait-for-visibility-at-most: PT4H
# how many entries a store dispatches at the same time; the workflow
# aggregate decides which thread takes an entry - see "Several entries leave
# at the same time". The JPA store and the MongoDB store both read it
dispatch-threads: 4
# how long successfully dispatched entries (marked DONE) are kept before
# they are deleted asynchronously - see "What the outbox guarantees"
retention: P7D
housekeeping:
# when the outbox removes what it does not need any more: the dispatched
# entries whose retention passed and the payloads no entry names. Inside
# the window it works off as much as fits, outside it neither costs
# anything - see "When the outbox throws things away"
start: 04:00
end: 05:00
# the zone the two times above are read in. Unset means the zone of the
# JVM, and a JVM standing on UTC without this key is warned once at the
# startup - it starts either way
#zone: Europe/Vienna
# whether to create what the outbox reads automatically - both tables,
# or both collections on MongoDB, and the indexes on them; disable if
# the database schema is managed manually (e.g. Flyway/Liquibase). On
# JDBC both tables are then checked at startup instead
create-schema: true
jdbc:
# disable the JPA default (e.g. if you define your own outbox)
enabled: true
# custom table name (the table is created automatically with this name)
#table: VANILLABP_PHASE_TWO_OUTBOX
# custom table name for the payloads; unset it follows the table above
#payload-table: VANILLABP_PHASE_TWO_OUTBOX_PAYLOAD
# custom name of the table the records of processed task deliveries
# live in; unset it is VANILLABP_TASK_DELIVERY, and unlike the payloads
# it does not follow a renamed outbox
#delivery-table: VANILLABP_TASK_DELIVERY
# custom name of the table the housekeeping takes its claim in; unset it
# is VANILLABP_HOUSEKEEPING, and like the deliveries it does not follow a
# renamed outbox
#housekeeping-table: VANILLABP_HOUSEKEEPING
mongo:
# disable the MongoDB default (e.g. if you define your own outbox)
enabled: true
# custom collection name
collection: vanillabp-phase-two-outbox
# custom collection name for the payloads; unset it follows the collection above
#payload-collection: vanillabp-phase-two-outbox-payloads
# custom name of the collection the records of processed task
# deliveries live in
delivery-collection: vanillabp-task-deliveries
# custom name of the collection the housekeeping takes its claim in
housekeeping-collection: vanillabp-housekeepingThe store of the payloads is named after the outbox it belongs to. If you set no name for it,
VanillaBP adds _PAYLOAD to the table name and -payloads to the collection name. So if you rename
the outbox, the payloads are renamed with it. Two applications on one schema need exactly that. They
keep apart by the name of the outbox. If the payloads stayed behind, the housekeeping of one
application would delete rows of the other.
Each of these names has to be different. The outbox, the payloads behind it and the records of delivered tasks are in the same database. So if two keys name the same table or collection, the startup ends and says which two keys they are. Table names are compared in capitals, the way a relational database looks them up. Collection names are compared as you wrote them.
A custom store also takes on a promise. VanillaBP calls your aggregate persistence during phase two, on whatever thread your store dispatches on. VanillaBP opens the transaction around that call. It is the transaction which belongs to the aggregate the entry names, so loading and saving share it, no matter which store dispatched. Your persistence must not expect the thread which served the request. That thread is long gone by then.
Applications which use another persistence technology (or an outbox infrastructure they already have) can define their own bean which implements io.vanillabp.integration.spi.PhaseTwoOutbox. As soon as there are several outboxes, you have to assign an outbox to specific aggregates. This is also how you move a high-load process to a store of its own. Define a bean which implements io.vanillabp.integration.spi.PhaseTwoOutboxAware<MyAggregate> and returns the outbox to use. The implementation with the most specific aggregate class wins, as for aggregate persistence. Every outbox instance needs its own store (table/collection). Two dispatchers polling the same store would compete and dispatch entries twice.
A custom store stores what the call carries and nothing more: the operation NAME (call.operation()), the workflow module, the BPMN process, the serialized aggregate ID, the optional adapter ID, the arguments and the idempotency key. It never interprets any of them. At dispatch time it hands the stored values back to VanillaBP's router, which works out the operation. Operation names may have a namespace (my-extension:NOTIFY), because extensions add operations of their own. So store the name as text of at least 255 characters.
- Every planned operation carries an idempotency key (why). The store keeps the key unique AMONG THE ENTRIES STILL WAITING for their dispatch. So if the same operation is planned again before the first one reached the BPMS, nothing happens. The key says that the operation is planned once, not that it ever happened. The same operation after the dispatch is a new one and is carried out. Starting a workflow is keyed by workflow module, BPMN process and aggregate ID. So there is one workflow per aggregate as long as the start is pending. Correlating a message gets a key only if you pass a correlation ID. Without one, the same message may be correlated again, and that is allowed. The key names its operation, so completing and cancelling one task no longer share it. A workflow start by message is the one exception, on purpose: it shares the key of the plain start.
-
A younger call may take the place of one still waiting. The rule above drops the second call. That is what a call which names an operation wants. A call which carries a payload wants the opposite. The payload is a state, and the older entry holds the older state. So such a call may ask to replace the entry instead, see work for after the commit. A store of your own which does not support this keeps the older state and writes a warning which names the store. Implement
PhaseTwoOutbox.scheduleReplacingWhatIsStillWaitingto support it. Never replace an entry which a dispatch has already taken. (why) - A dropped plan is logged as a warning. The warning names what was not planned, and both causes which VanillaBP cannot tell apart. Either something which already happened was dispatched again, or a second valid operation lost against one still waiting. See Message correlation for what a loop or a multi-instance activity has to do about it.
-
Dispatched entries are marked DONE, not deleted. They are removed in the background after
vanillabp.outbox.retention(7 days by default). That keeps them readable for support. It does not make the deduplication window longer, which ends with the dispatch. Make it shorter if the store grows too much, but do not set it to zero. Making it shorter no longer costs anything on the inbound side. The records of delivered tasks have their ownvanillabp.delivery.retention(see below). It follows this number only if you do not set it. -
Dispatch is at-least-once. The application may crash after the BPMS accepted an operation, but before the entry is marked DONE. Then the entry is dispatched again after the restart. For workflow starts, VanillaBP first probes the recorded BPMS. If the workflow is already known, it consumes the entry, so a duplicate start is unlikely. The window is small, but not closed, so never build on exactly-once. Your
@WorkflowTaskmethods should be idempotent anyway (see Workflow tasks). -
Entries which keep failing are blocked after
block-after-attemptsattempts. Then somebody has to look at them, see how long it tries, and how to open a blocked entry again. Watching the outbox store for blocked entries is a job for operations. Alert onvanillabp.outbox.blocked(the metrics). The gauge of waiting entries falls when an entry is blocked, so on its own it would look as if the backlog had cleared. -
A failure which the BPMS answers the same way every time is blocked at once. An adapter may report a failure as permanent (
MigratableProcessService.isPhaseTwoFailureRepeatable), for instance a command which Camunda 7 rejects as invalid. Repeating such an operation gains nothing. So the entry is blocked after the first attempt and logged as an error. The error names the operation, the workflow module, the BPMN process and the aggregate. Everything else stays repeatable, and that is why losing a concurrency conflict does no lasting harm. (why)
Two kinds of row leave the store: the entries which were dispatched longer ago than
vanillabp.outbox.retention, and the payloads which no entry names any more. Both used to be removed
at the end of every poll, so every application paid for them all day long. Now this happens in a
window at night, from vanillabp.outbox.housekeeping.start to .end. That is four to five in the
morning unless you set other times. Inside the window the store does as much as fits. Outside the
window, removing rows costs nothing.
(why)
You do not set how much fits. The first batch of a night is a thousand rows. VanillaBP measures how long it took. The next batch is twice as large, as long as twice that time still fits in the rest of the window. A batch which runs past the end halves the size. The next night starts at half of the largest batch which fitted, because your database will not be the same at four in the morning tomorrow.
Only one node does the housekeeping of a store per night. If two nodes did it at once, each one
would measure the other's work. So a node writes a claim into the store's own database, in the table
VANILLABP_HOUSEKEEPING or the collection vanillabp-housekeeping. The other nodes leave that store
alone until the window closes. The claim is per STORE. So in an application which runs a JPA outbox
and a MongoDB outbox side by side, two different nodes may do the housekeeping.
A node which is down for the whole window does no housekeeping that night. A pod scaled to zero at night, or one which is being rolled out at that moment, simply skips it. VanillaBP has no mechanism to catch up, because what such a mechanism should do would be a guess. The next night takes the backlog with it, and the three meters below show it happen.
Three meters say whether the window is wide enough: vanillabp.outbox.housekeeping.remaining
is what the window did not get to, .removed is how many rows it removed, and .window.used is how
much of the window it needed. All three are set when the window closes, and they stay until the
next one closes. Read them together. If the window was used up AND left something behind, the window
is too small, and you should make it wider. The remainder alone would not tell you that. A store with
work left may also be one which never did its housekeeping, because the node was down.
The window is read in a time zone, and an application on UTC is told so once. A container runs
on UTC unless somebody sets its zone. "Four in the morning" then means four UTC, which in most
places is the middle of the working day. Such an application starts, and VanillaBP warns while it
starts. The line names the hours at which the window really runs and the zone of the JVM. It also
names two ways to choose another zone, and neither needs a new build. Set the zone of the container
(TZ=Europe/Vienna), or set the environment variable of the property
(VANILLABP_OUTBOX_HOUSEKEEPING_ZONE=Europe/Vienna). Write UTC there if you mean UTC, and the line
goes away. It stays a warning because UTC is the normal case for a container. A refused start would
cost you a deployment. A window in the wrong zone only costs a cleanup at an unexpected hour.
Every store keeps the guarantees above. How each one keeps them differs:
| Store | How a duplicate is recognised | What happens when the same key is scheduled again | How a re-dispatch is recognised |
|---|---|---|---|
| JPA (the default) | a unique DEDUP_KEY column. It holds the key while the entry waits, and the entry's own id once it is DONE |
while it waits, the second schedule is dropped and logged. After the dispatch the key is free, so the same operation is a new one |
STATUS, ATTEMPTS and NEXT_ATTEMPT_AT of the row, plus LEASED_BY. An attempt which ended is counted there, and one whose node died left its name in the claim |
| MongoDB (Spring Boot) | a unique index on the entry's dedup field. It holds the key while the entry waits, and the entry's own id once it is DONE | while it waits, the second schedule is dropped and logged. After the dispatch the key is free, so the same operation is a new one | the status, the attempt count and the claim of the document, the same way |
| JDBC (Quarkus) | the store of the first row, running the same code | same | same |
| MongoDB (Quarkus) | as on Spring Boot | same | same |
All stores wait for VanillaBP in the same way. Spring Boot answers requests before the BPMN models
have reached the BPMS. So the application may be asked to start a workflow whose process the BPMS
does not have yet. An entry planned in that window waits for the first poll and does not go out at
once. This happens once per application start. It costs the time until the poller starts, not a full
vanillabp.outbox.poll-interval. If the entry were sent right away, the answer would be "no such
process". That is one of the failures which are blocked on the first attempt, and a start which was
only early would wait for somebody to repair it.
While 2.0 was being built, a Spring Boot application with JPA kept its phase-two entries in the
gruelbox transaction outbox, in the table TXNO_OUTBOX.
A version 1 application never wrote that table
(why). Now VanillaBP writes its
own store, and the gruelbox store lives in
its own repository. An application which
wants to keep it adds the artifact io.vanillabp:gruelbox-phase-two-outbox and changes no
configuration. That repository documents what the store does differently and what it cannot answer.
An application may have run such a snapshot and then upgrade without that artifact. At startup it is
told how many entries are left in TXNO_OUTBOX, because nothing reads that table any more. Dispatch
them with the snapshot you came from, and drop the table afterwards. The artifact is what asks for
that store, and the key vanillabp.outbox.gruelbox.enabled belongs to it. With the artifact, false
hands the entries back to VanillaBP's own store. Without the artifact, the line does nothing at all.
The outbox dispatches up to vanillabp.outbox.dispatch-threads entries at once, four unless you set
another number. A free thread does not just take the next entry. The workflow aggregate of the entry
decides which thread takes it. So two operations of one workflow reach the BPMS in the order in which
they were planned, while operations of different workflows run at the same time. Giving the next
entry to the next free thread would have fixed the throughput but broken the order. Nothing later in
the chain would have noticed, until a customer did. A
broadcast signal names no aggregate. So it is ordered
by its BPMN process instead, and two signals of one process keep their order as well.
(why)
Raise the number if the BPMS is so slow that the threads wait for it and not for the database. Every
dispatch costs a database connection and a call to the BPMS. So a bigger number only moves the limit
into the connection pool, where it is harder to see. In a thread dump, the dispatching threads are
the daemon threads named vanillabp-outbox-dispatch-0 and up. Next to them is the poller
vanillabp-outbox, which feeds them.
This does not keep the order of an entry whose dispatch FAILED. That entry waits for its backoff, and the next entry of the same aggregate passes it in the meantime. A single thread did the same, because a failed entry goes back into the table either way.
The MongoDB store dispatches the same way and reads the same setting.
The poll interval used to answer this question, but it does not any more. The poller asks its store
when the earliest entry it still owes something to is due, and it sleeps until exactly that moment.
So an entry goes out at its due time, whatever poll-interval says. After a commit, the entry goes
out at once, as it always did. The interval limits only one case: work which a node wrote down
before it went away. The node which writes an entry dispatches it itself, so nothing tells the other
nodes about that row. The limit is what makes somebody look anyway (see
running more than one node).
So the number controls cost, not latency. Left at ten seconds, an application keeps exactly the timing it always had. Set to ten minutes, an application with nothing to do pays one small query per store every ten minutes, instead of 8640 polls a day. The only thing it gives up is how soon the work of a node which died is picked up. Setting it back to seconds because an entry looked late buys nothing. That entry was waiting for its own due time, not for the poller.
When you change it, the startup log says what else is still awake. Read it before you conclude that
the database has gone quiet. The retention cleanup of the task delivery records still looks once an
hour, and only after a delivery was recorded. The gauge which counts the entries waiting in an
outbox asks for them while your monitoring scrapes, at most once per vanillabp.metrics.gauge-cache.
And how an adapter finds work for your handlers is up to that adapter, and its own documentation
explains it.
A failed dispatch is retried at a growing distance: attempt-frequency for the first retry, doubled
for every attempt after it, up to max-attempt-frequency. With the defaults that is thirty seconds,
then one minute, two, four, and five minutes from there on. That makes fifty attempts in total, which
is close to four hours of trying. The numbers answer two questions at once. The limit says how long
after a BPMS comes back the first entry reaches it: five minutes at worst. The attempts say how long
an outage the store survives without anybody's help. Four hours is a cluster upgrade, and that is the
length they were chosen for. If your BPMS will be down longer than that, raise block-after-attempts
before the maintenance, not after it.
When a node has claimed an entry, that node holds it, and the hold lasts longer than the distance
above. A poll claims an entry by writing down which node took it and how long the claim lasts: one
attempt-frequency. From the claim on, the node keeps moving that moment forward until the dispatch
is over. This starts at the claim and not at the dispatch, because an entry first waits for
the thread its aggregate travels on. A wait longer than the
claim would hand the entry to the next poll. So an operation which queues for its thread and then
needs a minute against a slow BPMS is carried out once, not once per attempt-frequency. No second
node picks it up in the meantime. If the node which carries it disappears, nothing moves the moment
forward any more. The claim runs out, and the next poll of any node takes over the entry. That is the
recovery which was always there, now with its own deadline instead of one tied to the retry distance.
block-after-attempts counts attempts, not claims. An attempt is counted when it ENDED, with
whatever result it had. So an entry which travels for a long time uses nothing of the budget, and an
entry which keeps failing uses one per failure. A slow BPMS is therefore never the reason why an
entry is blocked. On JPA the columns are LEASED_BY and LEASED_UNTIL, on MongoDB the fields
leasedBy and leasedUntil. Both are empty as long as nobody holds the entry.
Two things are worth knowing about how the distance grows. It applies to a dispatch which FAILED. An entry whose workflow the BPMS has not made searchable yet is a different case. It gets its own due time, which is the adapter's window and not this backoff. And every store which VanillaBP owns computes the growth itself.
Waiting for a read model is not an attempt. Sometimes the BPMS does not report the workflow of an
entry yet, for example a Camunda 8 cluster whose exporter is behind. Such an entry uses none of
block-after-attempts. It comes back after the adapter's window as often as needed. It is blocked
only when wait-for-visibility-at-most has passed since it was written. That key is not set by
default. Then it is the time which the attempts take with the growing backoff, about four hours. So a
read model which stopped gets as much time as a BPMS which is away. Counting each answer as an attempt
used to block entries after fifty windows, which were eight minutes of a stopped exporter. And those
entries did not come back when the exporter caught up. An entry which was blocked this way says so in
its ERROR, with the time it waited.
Every store uses the window exactly as the adapter named it. It is not read as an upper limit. A
window longer than attempt-frequency delays the entry, and a shorter one brings it forward. The
adapter knows when its BPMS can answer, and asking earlier only asks in vain. So the same adapter
answers the same way, whichever store you chose. That is why an adapter's documentation can name a
number at all.
A blocked entry stays in the store, and no retention removes it. The operation still has to happen,
and an entry deleted before it is finished is a workflow which silently never goes on. But a blocked
entry no longer stands in the way of its own repetition. On the stores VanillaBP owns, blocking
releases the deduplication key, exactly as a dispatched entry releases it. So the application can
plan that operation again, and the new attempt is an entry of its own. Whoever repairs the blocked
row has to expect a second row for the same operation. The counter vanillabp.outbox.discarded no
longer rises in this case, because there is nothing left to drop.
A blocked entry says why. On the stores VanillaBP owns, the column LAST_FAILURE (on MongoDB the
field lastFailure) holds the reason of the last attempt which did not get through. That is the class
and the message of what the dispatch threw, and of its causes, in one line and cut to 1000 bytes. The
stack trace stays in the log. Every attempt writes the reason, and an attempt which gets through
empties it. So an entry which is still being repeated says why as well. The gruelbox store keeps no
reason. Blocked outbox entries has the query which reads it.
The payload of a blocked entry stays with it. The retention counts from the entry. It deletes a dispatched entry together with the bytes which that entry named. It never takes the bytes of an entry which is still waiting or blocked. So an entry which is opened again weeks later still carries the state its caller planned it with. The cleanup by age deletes only payloads which no entry names any more. Such payloads are left behind by a write which never committed.
Keeping those bytes costs something while the entry stays blocked. Before the housekeeping removes a
payload by age, it asks whether an entry still names it. It asks in the database: one statement per
batch, and nothing of either set travels through your application. On MongoDB the entries are joined
with a $lookup, using the index on the reference inside the entry document. On a relational
database the outbox table has a column PAYLOAD_REFERENCE with an index on it, so the question is a
lookup there too. We measured it on PostgreSQL 16.15 on 2026-09-25. With a hundred blocked entries,
one pass took 1 ms, no matter whether ten thousand or a million dispatched entries were next to them.
With ten thousand blocked entries it took 20 ms. Repairing the blocked entries makes the cost go
away, and you should do that anyway.
How to find a blocked entry, open it again or delete it, on JPA and on MongoDB, is on its own page: Blocked outbox entries. The ERROR which reports a blocked entry links there too. That page also says what to check before you open an entry, because by then the operation may have a younger entry.
This is the inbound side of the outbox. A remote BPMS may hand out the same task again. VanillaBP records what it processed. So it answers a repeated delivery from that record and does not run the handler twice (see Workflow tasks). The store is chosen per workflow aggregate, exactly like the outbox. So a record always goes with the aggregate's own transaction:
- JPA/JDBC aggregates get a table
VANILLABP_TASK_DELIVERY. It is written through a connection bound to the transaction which Spring manages. If your database has naming rules,vanillabp.outbox.jdbc.delivery-tablegives the table a name of your own, the waytableandpayload-tabledo it for the outbox. It is a separate table, not the outbox table, on purpose. The outbox holds calls which wait to be dispatched. A delivery record is a fact to be read back. And the same store answers which BPMS holds a task when you complete it. That is what itsTASK_IDcolumn and the index on it are for. - MongoDB aggregates get a collection
vanillabp-task-deliveries, keyed by the delivery. So the document ID makes each record unique. If your database has naming rules,vanillabp.outbox.mongo.delivery-collectiongives the collection a name of your own, the waycollectionandpayload-collectiondo it for the outbox. Records go with the MongoDB transaction which Spring manages. That needs a replica set and aMongoTransactionManager. Without one, a record is written at once and removed again if its transaction does not commit. So a repeated delivery of work which was rolled back is not skipped. Only a process which dies in between leaves a record behind. - The STORE settings are those of the outbox, but the retention is not:
vanillabp.outbox.create-schemadecides whether VanillaBP creates the table or the index itself.vanillabp.outbox.jdbc.enabled/.mongo.enabledswitch the defaults off.vanillabp.outbox.mongo.delivery-collectionnames the MongoDB collection. Andvanillabp.delivery.retentiondecides how long a record is kept after the BPMS last asked about it. That last one is a property of its own, because it decides correctness, not how long support can read along. A delivery which arrives later than the retention finds no record and runs your handler a second time. On the outbox side, the deduplication window ends with the dispatch, and the retention only keeps a dispatched entry readable. If you do not set it, it followsvanillabp.outbox.retention, where the number lived before the two were separated. VanillaBP says at startup which of the two applies. It is read for the whole application, not per workflow module, because one cleanup per store deletes by age across the whole table. The BPMS asks about a task your application leaves open until somebody completes it. So the record of that task stays, while the retention deletes the records of the tasks which are done. - For any other persistence, write a bean of your own which implements
io.vanillabp.integration.spi.TaskDeliveryLog. If there are several, assign them to aggregates throughio.vanillabp.integration.spi.TaskDeliveryLogAware<MyAggregate>, exactly as for the outbox. Without any store, the startup says that deliveries are not deduplicated and names the way out. Such a store decides for itself how long a record lives. If the record of an open task has to survive in the same way, implementstillOpen. VanillaBP calls it with the key of every repeated delivery it answers. -
vanillabp.delivery.release-on-workflow-end(defaultfalse, can be changed per workflow module asvanillabp.workflow-modules.<id>.delivery.release-on-workflow-end) lets a workflow which ended delete its own records, instead of waiting for the retention, see Workflow tasks. Both stores above implement the deletion. If a store of your own does not, the startup names it together with the property.
In some applications the schema is a reviewed artifact which the deployment pipeline applies. Such an application switches VanillaBP's table creation off and applies the statements itself:
vanillabp:
outbox:
create-schema: falseWith that switch off, VanillaBP checks at startup that the tables in your relational database exist. If one is missing, it ends the boot and names the table, the property and the artifact to apply. That is better than failing at the first workflow start, hours after the deployment. (why)
The statements come from the artifact io.vanillabp:vanillabp-schema. It contains nothing but
schema definitions. So a repository which holds your migrations can depend on it without pulling in
the runtime:
<dependency>
<groupId>io.vanillabp</groupId>
<artifactId>vanillabp-schema</artifactId>
</dependency>With Liquibase, include the changelog from the classpath in your own:
<include file="vanillabp/schema/changelog.xml" />A table name you configured is a changelog property. So a renamed table needs no copy of the
changelog. Set vanillabp.outbox.table, vanillabp.delivery.table, vanillabp.payload.table or
vanillabp.housekeeping.table as a changelog parameter. Write the same name on both sides, or the
runtime looks for a table which the changelog did not build. Do not forget the payload table when you
rename the outbox. The runtime names it after the outbox table. So a changelog which renames only
vanillabp.outbox.table builds a payload table which VanillaBP will not look for. The delivery table
and the housekeeping table are not derived from anything. They follow only
vanillabp.outbox.jdbc.delivery-table or .housekeeping-table.
The generated Flyway files cannot follow a name of yours. They are generated while
io.vanillabp:vanillabp-schema is built. Nobody knows your names then, so they carry the defaults.
If you rename a table, apply the changelog with Liquibase, or edit the generated statements before
you apply them.
The changelog also carries the indexes, and they are not decoration. Each time the poller wakes up,
it asks its store when the earliest entry it owes something to is due. Without an index, that
question reads the whole table. We measured it on PostgreSQL 16 with 200000 rows: 11 to 18 ms
without the index against 0.05 ms with it. The first number grows with the table, the second does
not. The outbox table gets three indexes. Each one filters the same status and then reads a moment
of its own: when the next entry is due, what the retention may delete, and how long the oldest
waiting entry has already waited. The last one answers the gauge
vanillabp.outbox.oldest.pending.age. We measured it on PostgreSQL 16.15 in September 2026. Without
the index it took 0.17 ms with a backlog of 1000 entries, 28 ms with 100000 and 78 ms with 500000.
With the index it took 0.07 ms at every size. The table which holds the payloads has its own index,
on the moment a payload was written. The delivery table gets one for what its retention deletes by,
plus the ones its reads need. The fourth table holds the claim of the nightly housekeeping and has
no index. It has one row per store, and every read of it reads by that row's key. An application
which applied an earlier version of the changelog gets the indexes from the changeset of the version
which added them. If VanillaBP created the tables of an application, the startup says which index is
missing and which statement adds it. Creating an index on a large table locks it, so that is your
decision and not something a boot should do behind your back.
With Flyway, apply the generated SQL (vanillabp/schema/flyway/<database>/V<version>__vanillabp_schema.sql
in the same artifact) with a second Flyway instance of its own: its own locations and its own
history table. That matters. Flyway keeps one version timeline per history table. So VanillaBP's
versions would sooner or later clash with the numbers of your own migrations. With
flyway_schema_history_vanillabp as the history table, both timelines stay separate, and you can
upgrade each side without touching the other.
Every instance which is not the first one needs two settings, and both are required:
Flyway
.configure()
.dataSource(dataSource)
.locations("classpath:vanillabp/schema/flyway/postgresql")
.table("flyway_schema_history_vanillabp")
.baselineOnMigrate(true) // the schema already holds the tables of the other owners
.baselineVersion("0") // the default baseline is 1, which would skip V1.0.0 silently
.load();baselineOnMigrate lets an instance work in a schema which is not empty, and next to another history
that is always the case. Without it, Flyway finds tables it has no history for and refuses with
Found non-empty schema(s) "PUBLIC" but no schema history table. baselineVersion is needed as
well. The default baseline is version 1, so every migration numbered 1.0.0 counts as applied and
is skipped. Flyway then reports success, but the table never appears.
A FlywayMigrationInitializer per instance makes sure the schema exists before anything reads it.
Spring Boot's Flyway module lets the entity manager factory depend on beans of that type, not on
Flyway beans. So an instance without an initializer migrates whenever its bean happens to be
created, and that may be after Hibernate looked.
Which databases: tests cover H2 and PostgreSQL. MySQL, MariaDB, SQL Server, Oracle and DB2 ship
as well, generated by Liquibase from the same changelog, but nothing here runs them. It is the same
list the Camunda 7 engine serves, so VanillaBP never supports fewer databases than the engine.
Oracle and DB2 count a VARCHAR in bytes. What that means for the ID of your aggregate is in
Workflow aggregates.
The blueprints persistence-liquibase and
persistence-flyway build and test both ways. They include the three owners of schema which a
workflow application usually has: VanillaBP, the workflow module and the application itself.
vanillabp.outbox.create-schema reaches the MongoDB stores as well, but there it means something
else. MongoDB creates a collection with its first document. So there is no collection to create, and
none to check either. What VanillaBP would have created are the indexes which answer its questions.
With the switch off, they are your job:
db.getCollection("vanillabp-phase-two-outbox").createIndex({ "dedupKey": 1 }, { "unique": true });
db.getCollection("vanillabp-phase-two-outbox").createIndex({ "status": 1, "nextAttemptAt": 1 });
db.getCollection("vanillabp-phase-two-outbox").createIndex({ "status": 1, "doneAt": 1 });
db.getCollection("vanillabp-phase-two-outbox").createIndex({ "status": 1, "createdAt": 1 });
db.getCollection("vanillabp-phase-two-outbox").createIndex({ "args.payloadReference": 1 }, { "sparse": true });
db.getCollection("vanillabp-phase-two-outbox-payloads").createIndex({ "createdAt": 1 });
db.getCollection("vanillabp-task-deliveries").createIndex({ "lastSeenAt": 1 });
db.getCollection("vanillabp-task-deliveries").createIndex({ "taskId": 1 });
db.getCollection("vanillabp-task-deliveries").createIndex({ "aggregateId": 1 });
db.getCollection("vanillabp-task-deliveries").createIndex({ "workflowId": 1 });These are the default names. If you gave a collection a name of your own with
vanillabp.outbox.mongo.collection, vanillabp.outbox.mongo.payload-collection or
vanillabp.outbox.mongo.delivery-collection, use that name instead.
You do not have to copy this list to find out what is missing. At startup, VanillaBP reads which
indexes each of the three collections has. It warns about every missing index, with the statement
above and a word on what reads it. That stays a warning. The application boots, and until the index
exists, the question reads the whole collection. That costs more the longer the application runs.
Act on the unique index on dedupKey first. It is not about speed. Without it, two nodes which write
the same operation at the same moment both plan it, and the BPMS is called twice.
The Camunda 7 engine tables are the engine's job too. Its JAR ships a Liquibase changelog
(org/camunda/bpm/engine/db/liquibase/camunda-changelog.xml) and plain create scripts. Refer to those
instead of copying them, because the engine version decides what is correct.
Each BPMS the application uses is configured as a named adapter below vanillabp.adapters. The key is the adapter ID, which names one instance of a BPMS. Its type names the BPMS adapter which serves it.
An application with one BPMS adapter dependency and one workflow module needs no vanillabp.* property at all. VanillaBP works out everything it can by itself:
- the adapter: the single adapter type found in the classpath becomes the one configured adapter (its ID is the adapter type). Because it is the only one, it is also the prioritized adapter;
- the workflow module: every module found in the classpath (a
META-INF/workflow-moduledescriptor) is known without a section of its own; - the BPMN location: see the resources-location convention.
You only have to write the settings which the BPMS itself needs (e.g. the address of a remote cluster). They go into the adapter's own section:
vanillabp:
adapters:
camunda8: # the ID names the adapter type ...
rest-address: http://localhost:8080 # ... only the BPMS' own settings remainThe conventions at work:
-
The adapter's type defaults to its ID. You need
vanillabp.adapters.<id>.typeonly for a custom adapter ID (old-bpms,c7-legacy, …) which does not match an adapter type. Custom IDs let you run several instances of the same BPMS. VanillaBP can never derive them, so they always need theirtype. This default exists for applications which upgrade from VanillaBP 1, where there was one adapter and its ID was its type. It is not a recommendation. A good ID says WHICH system it is (camunda8-saasnext tocamunda8-onprem,camunda7-loansnext tocamunda7-claims). An operator reads it in a log line, in a metric tag and in a process definition id. And a migration from one Camunda 8 configuration to another needs two names anyway, because both cannot be calledcamunda8. An adapter ID must never be renamed while workflows are running. VanillaBP stores it to know what belongs to which BPMS. See naming adapter ids, and never renaming them. -
A single configured adapter is the prioritized adapter. You may leave out
vanillabp.prioritized-adaptersas long as exactly one adapter is configured. -
vanillabp.retired-adaptersnames the adapter ids which this application USED to have. At startup, VanillaBP asks its stores for which ids they still hold waiting outbox entries or records of open tasks. It names any such id which is no longer configured. Either the id was renamed, which loses workflows, or it was removed too early. Naming it here says that you know about the leftovers, see naming adapter ids, and never renaming them.
With several adapters in the classpath, VanillaBP never guesses which one starts new workflows. But as long as the adapter IDs are adapter types, naming the order is all you need:
vanillabp:
prioritized-adapters:
- camunda8 # new workflows start here
- camunda7 # existing workflows complete hereVanillaBP derives the adapter sections from that list. You have to write down everything which differs from the convention. A custom ID needs its type. Two IDs of the same type must be distinguishable. What that means is up to the adapter, e.g. separate databases or separate cluster endpoints.
vanillabp.prioritized-adapters defines the order in which adapters are used. You can set again which adapter is asked first for a workflow module and for a single workflow: the levels a setting is written at. New workflows always start in the first adapter of the list which counts for them. Existing workflows are found by asking the adapters in order of priority (cached after the first successful election). That is what makes BPMS migration possible without touching business code.
vanillabp:
prioritized-adapters:
- camunda8 # new workflows start here
- legacy # existing workflows complete here
adapters:
camunda8:
rest-address: http://localhost:8080 # ID = type, so 'type' can be omitted
legacy:
type: camunda7 # a custom ID needs its type
# optional: do not abort booting if deployment to this (non-first-priority)
# BPMS fails, e.g. because the old BPMS is temporarily unreachable during
# a migration; 'fail' (the default) aborts booting
deployment-failure: warnSettings of one adapter (e.g. the connection to a remote BPMS) go into the SAME section. Each
adapter adds its own keys to vanillabp.adapters.<id>.* (see your adapter's wiki
for its keys). There is no separate namespace per adapter. A key which differs by scope may be
written again for a workflow module, for a workflow and for a single task:
the levels a setting is written at.
deployment-failure: warn only works for adapters which are not first in the priority list, neither for the workflow module nor for any of its workflows. If an adapter has the first priority anywhere, a failed deployment to it always stops the boot. Otherwise new workflows could not be started.
You can override the prioritized adapters for a single workflow (BPMN process) of a workflow module. This is how you move one process to a new BPMS while the rest of the module stays:
vanillabp:
prioritized-adapters:
- camunda7
- camunda8
workflow-modules:
loan-approval:
prioritized-adapters:
- camunda7 # the module still runs on Camunda 7...
- camunda8
workflows:
RiskAssessment:
prioritized-adapters:
- camunda8 # ...but new 'RiskAssessment' workflows start in Camunda 8
- camunda7Two things happen automatically:
- Deployment union: the resources of the workflow module are deployed to every adapter named at the module level or at any workflow level. Each such adapter gets all the resources of the module, because deployment works per file while the election works per process. So an adapter named only for a single workflow still gets the deployment it needs to start that workflow.
-
Validation: a workflow ID below
workflowsmay match no executable BPMN process of the module. That does not stop the boot, because the BPMN may arrive later during a migration. But a warning at startup reports it and names the known BPMN process IDs. If aprioritized-adaptersentry at workflow level names an unknown adapter ID, the boot stops, as on the other levels.
You can override all vanillabp.* properties with environment variables (e.g. VANILLABP_ADAPTERS_CAMUNDA8_DEPLOYMENT_FAILURE=warn). One limit applies on all platforms. An environment variable can only override an adapter or workflow module which a configuration file already declares. It cannot introduce a new entry whose ID contains dashes or dots. The binder cannot rebuild the ID from the variable's name (C8_CLOUD could mean c8-cloud, c8.cloud or a nested key). VanillaBP does not silently ignore such a variable. It fails the startup with a message which names the variable, the configured IDs and the fix: declare the ID in a configuration file (e.g. vanillabp.adapters.<id>.type) and use environment variables to override its values.
Look twice at a key with a dash in it, because the name of a variable is not the name of the property. Spring Boot writes the property name in capitals, turns every dot into an underscore and drops every dash. So vanillabp.adapters.camunda8.rest-address becomes VANILLABP_ADAPTERS_CAMUNDA8_RESTADDRESS. That form works wherever the key stands. Spring Boot also understands a dash written as an underscore, as long as every dash of the name is written that way. So VANILLABP_ADAPTERS_CAMUNDA8_REST_ADDRESS arrives too. This stops working where the part with the dash is a section with an ID below it. Spring Boot has to read that ID out of the variable, and then WORKFLOW_MODULES reads as workflow.modules. So VANILLABP_WORKFLOW_MODULES_LOAN_RESOURCES_LOCATION reaches nothing, but VANILLABP_WORKFLOWMODULES_LOAN_RESOURCESLOCATION does. Write the form without dashes, and it is right in both places.
Quarkus reads the other spelling. There every dash is always an underscore, and the name without it reaches nothing (see the same section on the Quarkus page). So an application deployed on both platforms, or a pipeline which runs one example on both, sets two variables for one property. Nothing reports the wrong one. A variable which matches no property is not read, and the setting keeps the value from the configuration file.
