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 both monolithic applications and microservices. It scales from small, single-module projects to large, multi-module Maven or Gradle builds. The chosen setup determines which dependencies are required. -
Add the required dependencies to the correct Maven/Gradle modules
VanillaBP provides a small set of dependencies, each with a clearly defined purpose. Add each of them where it belongs. -
Configure the transaction outbox
Required only for adapters of remote BPMS: VanillaBP uses a transaction outbox to start workflows reliably. It works out of the box and can be tuned viavanillabp.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 asks of you. -
Creating the tables with Liquibase or Flyway
The three tables VanillaBP owns, written out for a project which manages its schema itself. -
Configure the adapters
Each BPMS used gets a section belowvanillabp.adapters. Optional settings cover the behavior on deployment failures and resilience against temporarily unavailable BPMS.
VanillaBP supports business process applications built as a monolith or as microservices. Both approaches work for single-module setups as well as for large, multi-module Maven or Gradle projects.
If you use multiple modules, you typically separate workflow modules from the main Spring Boot application. You find concrete examples on the workflow modules page.
To increase decoupling, each workflow module can provide its own configuration files (YAML and/or .properties) named after the workflow module ID. These files are automatically loaded and merged into the Spring Environment as defaults: whatever the application configures wins over them, application.yaml and application-prod.yaml included. Profile-specific variants (e.g. loan-approval-prod.yaml) are supported as well. In multi-module projects, the configuration files can be placed in a subdirectory named after the workflow module ID (e.g. loan-approval/loan-approval.yaml), following the same convention used for all other workflow module resources.
The key principle: place everything a workflow module needs into its own configuration files. The application.yaml should only contain values that are truly cross-cutting — such as database settings, server configuration, or VanillaBP adapter definitions. Module-specific properties like endpoint URLs, timeouts, or feature flags belong in the module's own files. This way, the workflow module increasingly becomes a self-contained application within the application. See Configuration for details and precedence rules.
Depending on your project structure, you need to add the following dependencies.
-
VanillaBP adapter dependencies:
For each BPMS your application uses, add exactly one corresponding VanillaBP adapter to the main application module (in multi-module setups) or to the single module building the Spring Boot application. The adapter dependency transitively includes the VanillaBP Spring Boot integration, so no additional VanillaBP dependency is needed. See Available BPMS adapters for the list of adapters and their documentation. -
io.vanillabp:vanillabp-spring-boot-support:
This dependency contains the VanillaBP APIs required for compilation. Add it to pure workflow modules in multi-module setups. It keeps workflow modules independent of the concrete BPMS: BPMS-specific APIs are out of reach on purpose, so the hexagonal architecture cannot be violated by accident. The module's README lists the APIs it includes. It also brings the integration SPI along, the interfaces your application may implement where VanillaBP needs something only your application knows, aggregate persistence above all.
All VanillaBP artifacts an application uses are version-managed by io.vanillabp:vanillabp-bom. Import it once in the parent POM and omit 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").
Which patch releases of the 2.0 line exist is listed on Maven Central.
Two things are worth knowing about it:
- BPMS adapters are not part of the BOM. Each adapter is released independently of the platform integration, so its dependency carries its own version (see Available BPMS adapters).
- The BOM must never be older than your adapters. A version managed by your build always wins over the version an adapter needs transitively, and Maven applies this silently, even when it means a downgrade: the build stays green while the application would fail at runtime. Adapters therefore verify the platform version at startup and abort with a message naming the artifacts to raise. Raising the BOM to the version reported fixes it.
Adapters connecting to a remote BPMS (e.g. a SaaS offering) cannot take part in your application's local database transaction. To avoid inconsistencies (like a workflow started in the BPMS without the corresponding workflow aggregate being persisted), VanillaBP starts such workflows in two phases and schedules the second phase through a transaction outbox: an entry written in the same local transaction as the workflow aggregate and processed reliably after the commit, even if the application crashes and is restarted 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 phase-two work, and an operation an extension registered for itself is another. Both are written into the same outbox, in the same transaction, and both run once it committed.
The embedded engine Camunda 7 uses the same path, although it could join your transaction: an operation which loses a concurrency conflict cannot be repeated inside the caller's transaction, and repeating only the engine part would advance the workflow while your data rolls back. An application on Camunda 7 therefore needs an outbox as well (see the adapter's wiki).
This works out of the box:
-
JPA: entries are stored in the table
VANILLABP_PHASE_TWO_OUTBOX, and the payload of an entry which carries one inVANILLABP_PHASE_TWO_OUTBOX_PAYLOAD. Both tables are created automatically and both belong toio.vanillabp:vanillabp-schema. It is the store a Quarkus application runs as well, written once for the two platforms. -
MongoDB: entries are stored in the collection
vanillabp-phase-two-outbox, and the payload of an entry which carries one invanillabp-phase-two-payloads. Both collections are VanillaBP's own, and MongoDB creates a collection with the first write, so neither has to exist beforehand;vanillabp.outbox.create-schemadecides whether the indexes they are read by are created at startup. A payload is deleted when its entry is dispatched, and the housekeeping deletes what is left over aftervanillabp.outbox.retention: the payload of an entry which stayed blocked, and the one a write outside a MongoDB transaction left behind when that transaction did not commit. A backup which covers only the outbox collection therefore restores entries whose bytes are gone. To get transactional behavior, MongoDB has to run as a replica set and aMongoTransactionManagerbean has to be defined - Spring Boot auto-configures none. Without it the startup ends with a message naming it, since an application whose aggregates nothing covers cannot start a workflow at all.
The outbox is selected per workflow aggregate: the entry always rides the transaction persisting the aggregate. In a mixed-persistence application (e.g. a JPA workflow module and a MongoDB workflow module during a migration) both defaults are active at the same time and each aggregate is served by the outbox matching its persistence, detected from the aggregate's Spring Data repository. The availability of a required outbox is validated at startup: if the first-priority adapter of a process needs the two-phase start and no outbox can be resolved, the application fails to boot with a message naming the remedies.
The outbox can be tuned using 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 many entries the JPA store dispatches at the same time; the workflow
# aggregate decides which thread takes an entry - see "Several entries leave
# at the same time". The MongoDB store keeps its one thread
dispatch-threads: 4
# how long successfully dispatched entries (marked DONE) are kept before
# they are deleted asynchronously - see "What the outbox guarantees"
retention: P7D
# whether to create the outbox table/collection automatically; disable
# if the database schema is managed manually (e.g. Flyway/Liquibase)
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 name of the table the payloads are stored in
#payload-table: VANILLABP_PHASE_TWO_OUTBOX_PAYLOAD
mongo:
# disable the MongoDB default (e.g. if you define your own outbox)
enabled: true
# custom collection name
collection: vanillabp-phase-two-outbox
# custom name of the collection the payloads are stored in
payload-collection: vanillabp-phase-two-payloadsA custom store also inherits a promise: VanillaBP calls your aggregate persistence during phase two, on whatever thread your store dispatches on. The transaction around that call is VanillaBP's to open, and it is the one belonging to the aggregate the entry names, so load and save share it whichever store dispatched. What your persistence must not expect is the thread which served the request. That one is long gone by then.
Applications using another persistence technology (or an existing outbox infrastructure) can define their own bean implementing io.vanillabp.integration.spi.PhaseTwoOutbox. Assigning an outbox to specific aggregates is required as soon as several outboxes exist, and it is the way to isolate a high-load process onto a dedicated store: define a bean implementing io.vanillabp.integration.spi.PhaseTwoOutboxAware<MyAggregate> returning the outbox to use (the implementation with the most specific aggregate class wins, like for aggregate persistence). Every outbox instance needs its own store (table/collection): two dispatchers polling the same store would compete and double-dispatch.
A custom store persists 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 persisted values back to VanillaBP's router, which resolves the operation. Operation names may be namespaced (my-extension:NOTIFY) because extensions contribute operations of their own, so store the name as text of at least 255 characters.
- Every scheduled operation carries an idempotency key (why) the store enforces as unique AMONG THE ENTRIES STILL WAITING for their dispatch, so scheduling the same operation again before the first one reached the BPMS is a no-op. The key says 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 is keyed only if you pass a correlation ID (without one, the same message may legitimately be correlated again). The key names its operation, so completing and cancelling one task no longer share it. A workflow start by message is the one deliberate exception: it shares the plain start's key.
-
A younger call may take the place of one still waiting. The rule above drops the second call, which is what a call naming an operation wants. A call carrying a payload wants the opposite, because the payload is a state and the older entry holds the older one, so such a call may ask to replace the entry instead - see work for after the commit. A store of your own which never learned this keeps the older state and writes a warning naming itself; implement
PhaseTwoOutbox.scheduleReplacingWhatIsStillWaitingto support it, and never replace an entry a dispatch has already taken. (why) - A discarded schedule is logged as a warning, naming what was not planned and both causes VanillaBP cannot tell apart: a repeated dispatch of something which happened already, or a second legitimate 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 asynchronously after
vanillabp.outbox.retention(7 days by default), which keeps them readable for support; it does not extend the deduplication window, which ends with the dispatch. Shorten it if the store grows too much; do not set it to zero. Shortening it no longer costs anything on the inbound side: the records of delivered tasks havevanillabp.delivery.retentionof their own (see below), which follows this number only where you do not set it. The one exception is gruelbox, which a JPA application can opt into, see what the stores do differently. -
Dispatch is at-least-once. A crash between the BPMS accepting an operation and the entry being marked DONE makes the entry be dispatched again after the restart. For workflow starts VanillaBP probes the recorded BPMS first and consumes the entry if the workflow is already known, which makes a duplicate start unlikely. The window is minimized, not closed, so never build on exactly-once semantics. Your
@WorkflowTaskmethods should be idempotent anyway (see Workflow tasks). -
Entries that keep failing are blocked after
block-after-attemptsattempts and then need manual attention, see how long it tries, and how to open a blocked entry again. Monitoring the outbox store for blocked entries is an operations duty, andvanillabp.outbox.blocked(the metrics) is what to alert on: the gauge of waiting entries falls when one is blocked, so it alone would read as if the backlog had drained. -
A failure the BPMS answers the same way every time is blocked right away. An adapter may report a failure as permanent (
MigratableProcessService.isPhaseTwoFailureRepeatable), for instance a command 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 naming the operation, the workflow module, the BPMN process and the aggregate it belonged to. Everything else stays repeatable, which is what makes losing a concurrency conflict survivable. (why)
The guarantees above are the contract every store keeps. How each one keeps them differs, and the store you can opt into keeps one of them only halfway, because gruelbox owns its table and VanillaBP cannot add a column to it:
| 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, which holds the key while the entry waits and the entry's own id once it is DONE |
while it waits, the second schedule is discarded 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 |
| MongoDB (Spring Boot) | a unique index on the entry's dedup field, which holds the key while the entry waits and the entry's own id once it is DONE | while it waits, the second schedule is discarded and logged. After the dispatch the key is free, so the same operation is a new one | the status and the attempt count of the document |
| JDBC (Quarkus) | the store of the first row, running the same code | same | same |
| MongoDB (Quarkus) | as on Spring Boot | same | same |
| gruelbox (JPA, opt-in) | gruelbox' own unique request id, which spans its retained entries as well | while the first entry waits, the second schedule is discarded and logged. Once the first one was dispatched, its row is DELETED to free the key and the new operation is scheduled - so the trail of that dispatch ends there | gruelbox counts an attempt when one FAILS. A hard crash between the BPMS command and the entry being marked processed therefore looks like a first attempt, and the re-dispatch mitigation of a workflow start does not run |
Two things follow for an application which opted into gruelbox. Support loses the trail of a dispatched entry exactly where it would be interesting, which is a second operation carrying the same key. And a duplicate start after a hard crash is mitigated by everything except the probe, so the residual window is wider there than on the other stores.
A blocked entry on that store keeps its key too. The key is meant to deduplicate what is still waiting, and a blocked entry waits for nobody, so on every other store the same operation can be planned again while the blocked row sits there for repair. On gruelbox it cannot until somebody removes the row, and a second attempt is refused with the warning of a discarded schedule. Worth knowing since a failure the adapter calls permanent is blocked on the first attempt: repair the row before you ask again.
What every store does alike is wait for VanillaBP. Spring Boot answers requests before the BPMN models have
reached the BPMS, so an application can be asked to start a workflow its BPMS does not hold the process for yet.
An entry scheduled in that window waits for the first poll instead of going out at once, which happens once
per application start and costs the moment the poller starts rather than a full
vanillabp.outbox.poll-interval. Sent right away it would be answered
with "no such process", one of the failures blocked on the first attempt, and a start which was only early would
be waiting for somebody to repair it.
Keeping gruelbox is a decision an application makes for itself, and the last row of the table is what it costs.
Set vanillabp.outbox.gruelbox.enabled to true and the default steps aside. VanillaBP no longer puts that
library on the classpath, so add com.gruelbox:transactionoutbox-core and com.gruelbox:transactionoutbox-spring
yourself; asking for the store without them ends the boot with a message which says so. What you keep is
TXNO_OUTBOX with everything waiting in it. What you take on is the fixed retry distance, the key a blocked entry
holds on to and the row a second schedule deletes. An application which upgrades without opting in is told at
startup how many entries are left in TXNO_OUTBOX, because nothing reads that table any more: dispatch them with
your previous version, or switch gruelbox on.
The outbox dispatches up to vanillabp.outbox.dispatch-threads entries at once, four of them unless you say
otherwise. Which thread takes an entry is not decided by which thread is free. It is decided by the workflow
aggregate the entry belongs to, so two operations of one workflow reach the BPMS in the order they were planned,
while operations of different workflows travel at the same time. Handing the next free thread the next entry would
have fixed the throughput and broken the order, and nothing downstream 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 where the BPMS is slow enough that the threads wait for it rather than 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 upwards, next to the poller vanillabp-outbox which feeds them.
What this does not order is an entry whose dispatch FAILED. It waits for its backoff, and the next entry of the same aggregate passes it in the meantime, which is what a single thread did as well: a failed entry goes back into the table either way.
The MongoDB store dispatches on one thread and does not read this setting. Neither does gruelbox, which submits through an executor of its own.
The poll interval used to be the answer to that, and it is not any more. The poller asks its store when the
earliest entry it still owes something to is due and sleeps until exactly that moment, so an entry goes out at its
due time whatever poll-interval says. After a commit it goes out at once, as it always did. What the interval
bounds is one case only: work a node wrote down before it went away. Nothing tells the other nodes about that row,
because the node which writes an entry dispatches it itself, and the cap is what makes somebody look anyway (see
running more than one node).
So the number is a cost dial rather than a latency dial. 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, and 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 is the one move which buys nothing: that entry was waiting for its own due time, not for the poller.
When you move it, the startup log says what else is still awake, and it is worth reading before concluding 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 what an adapter does to find work for
your handlers is that adapter's own business, which its own documentation explains.
A failed dispatch is retried at a growing distance: attempt-frequency for the first retry, doubled for every
attempt after it, capped at max-attempt-frequency. With the defaults that is thirty seconds, then a minute, two,
four, and five minutes from there on, fifty attempts in total, which is close to four hours of trying. The numbers
answer two questions at once. The cap says how long after a BPMS comes back the first entry reaches it, five
minutes at worst, and the attempts say which outage the store survives without anybody's help - four hours is a
cluster upgrade, and that is the length they were chosen for. An application whose BPMS is taken down longer than
that raises block-after-attempts before the maintenance rather than after it.
Two things are worth knowing about the way 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 and keeps its own short due time, which is
the adapter's window and not this backoff. And the growth is what the stores VanillaBP owns compute themselves,
while gruelbox brings a retry policy of its own which knows one fixed distance: an application which opted into
that store retries a failed entry every attempt-frequency until the attempts are used up, so with the defaults it
tries for twenty-five minutes rather than four hours. That is the third thing the table above cannot promise for
that store.
The short due time of a workflow which is not searchable yet is the exception, and gruelbox is given it in another
way. Its own policy has already written the next attempt when VanillaBP hears of the failure, so the window the
adapter named is written over it, by the same listener which blocks a failure the adapter calls permanent. The
entry is therefore due here when it would be due on the JDBC store, and the poller picks it up at its next turn,
at most poll-interval after that moment. Nothing waits on the dispatching thread for it, so the entries of every
other workflow go out while that one is not due yet. What ends a workflow which never becomes searchable is the
attempt counter, not the due time: every turn counts an attempt, and block-after-attempts of them block the
entry.
A blocked entry stays in the store, and no retention removes it: the operation still has to happen, so an entry
deleted unfinished is a workflow which quietly never went on. What a blocked entry does NOT do any more is stand
in the way of its own repetition. Blocking releases the deduplication key on the stores VanillaBP owns, 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 therefore has to expect a second row for the same operation - the counter
vanillabp.outbox.discarded no longer rises for this case, because there is nothing left to discard. On gruelbox
the request id is held by the row until it is removed, so there the dead end remains: as long as the blocked entry
sits in TXNO_OUTBOX, the same operation cannot be scheduled anew.
Where the way back leads depends on the store serving that aggregate. On JPA it is a statement which sets the entry open and due again:
UPDATE VANILLABP_PHASE_TWO_OUTBOX
SET STATUS = 'OPEN', ATTEMPTS = 0, NEXT_ATTEMPT_AT = CURRENT_TIMESTAMP
WHERE ID = '...';On MongoDB the same three fields are set in the document of the collection vanillabp-phase-two-outbox:
db.getCollection('vanillabp-phase-two-outbox').updateOne(
{ _id: '...' },
{ $set: { status: 'OPEN', attempts: 0, nextAttemptAt: new Date() } })attempts has to go back to zero as well, since the dispatcher only picks up entries whose attempts are still
below block-after-attempts. Nothing tells the poller about your statement, so the entry goes out at the next
wake-up, which is at most poll-interval away (see
how late an entry can be dispatched); restarting the node is the way to
have it picked up at once.
An application which opted into gruelbox goes another way, because that library owns its table and offers the
operation itself. A blocked entry is a row of TXNO_OUTBOX whose blocked column is set while processed is not,
and TransactionOutbox.unblock(entryId), called inside a transaction, clears the flag and resets the attempt count
so the next flush picks the entry up. VanillaBP's instance is the bean vanillaBpTransactionOutbox, and what else
that store offers is written in
gruelbox' own documentation. The columns of TXNO_OUTBOX are
gruelbox' own, so nothing here promises them; VanillaBP configures the outbox and does not add to its schema.
Reopening is where at-least-once gets real. An entry may be blocked because the BPMS answered too late rather than not at all, and then the reopened dispatch carries the operation there a second time. The idempotency key says nothing about that: it is about scheduling an operation once, not about whether it ever arrived. What carries the case is what carries it everywhere else. A workflow start probes the recorded BPMS and consumes its entry where the workflow is already there, and completing a task which is completed already is a warned no-op. A message correlation is the operation where a repetition can really do the work a second time, so an entry carrying one is the entry to read before you reopen it.
Editing the store is the supported way today. A user interface which does this without a database client and without code of your own is planned.
The inbound counterpart of the outbox: a remote BPMS may hand the same task out again, and VanillaBP records what it processed so a repeated delivery is answered from that record instead of running the handler twice (see Workflow tasks). The store is picked per workflow aggregate, exactly like the outbox, so a record always rides the aggregate's own transaction:
- JPA/JDBC aggregates get a table
VANILLABP_TASK_DELIVERY, written through a connection bound to the Spring-managed transaction. It is a table of its own and not the outbox table, on purpose: the outbox holds calls waiting 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, which is what itsTASK_IDcolumn and the index on it are for. - MongoDB aggregates get a collection
vanillabp-task-deliverieskeyed by the delivery, so uniqueness comes from the document ID. Records ride the Spring-managed MongoDB transaction, which needs a replica set and aMongoTransactionManager. Without one a record is written immediately and removed again when its transaction does not commit, so a redelivery of rolled-back work is not skipped - only a process dying in between leaves one behind. - The STORE settings are the outbox' ones and the retention is not:
vanillabp.outbox.create-schemadecides whether VanillaBP creates table respectively index itself,vanillabp.outbox.jdbc.enabled/.mongo.enabledswitch the defaults off, 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 rather than how long support can read along: a delivery arriving later than the retention finds no record and runs your handler a second time, while on the outbox side the deduplication window ends with the dispatch and the retention only keeps a dispatched entry readable. Where you do not set it, it followsvanillabp.outbox.retention, which is where the number lived before the two were told apart, and VanillaBP says at startup which of the two applies. It is read globally and not per workflow module: one cleanup per store deletes by age across the whole table. A task your application leaves open is asked about until somebody completes it, so its record stays while the retention deletes the records of the tasks which are done. - Any other persistence is served by a bean of your own implementing
io.vanillabp.integration.spi.TaskDeliveryLog, assigned to aggregates viaio.vanillabp.integration.spi.TaskDeliveryLogAware<MyAggregate>where several exist, exactly as the outbox does it. 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; where the record of an open task has to survive the same way, implementstillOpen, which VanillaBP calls with the key of every redelivery it answers. -
vanillabp.delivery.release-on-workflow-end(defaultfalse, overridable 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; a store of your own which does not is named at startup together with the property.
An application whose schema is a reviewed artifact applied by its deployment pipeline 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 exist and ends the boot when one is missing, naming the table, the property and the artifact to apply - instead of failing at the first workflow start, hours after the deployment. (why)
The statements come from the artifact io.vanillabp:vanillabp-schema, which contains nothing but
schema definitions, so a repository holding your migrations can depend on it without pulling 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 fork of the
changelog: set vanillabp.outbox.table, vanillabp.delivery.table respectively
vanillabp.payload.table as a changelog parameter.
The changelog carries the indexes as well, and they are not decoration. The poller asks its store on every wake-up when the earliest entry it owes something to is due, and without an index that question reads the whole table: measured on PostgreSQL 16 with 200000 rows, 11 to 18 ms against 0.05 ms, and the first number grows with the table while the second does not. The outbox gets two of them, because its two questions filter the same status and order by different moments, and the delivery table gets one for what its retention deletes by plus the ones its reads need. An application which applied an earlier version of the changelog picks them up from the changeset of the version which added them. An application whose tables VanillaBP created is told at startup which index is missing and which statement adds it, because creating an index on a large table is a decision with a lock on it rather than 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 is not a detail: Flyway keeps one version timeline per history table, so
VanillaBP's versions would sooner or later collide with the numbers of your own migrations. With
flyway_schema_history_vanillabp as the history table, both timelines stay separate and each side
can be upgraded without touching the other.
Two settings belong to every instance which is not the first one, and neither is optional:
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 is what lets an instance work in a schema that 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 has to
follow, because the default baseline is version 1, so every migration numbered 1.0.0 counts as
applied, is skipped, and Flyway reports success while the table it describes never appears.
What makes the schema exist before anything reads it is a FlywayMigrationInitializer per instance.
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, which may be after Hibernate looked.
Which databases: H2 and PostgreSQL are covered by tests. MySQL, MariaDB, SQL Server, Oracle and DB2 ship as well, generated by Liquibase from the same changelog, but nothing here runs them - the same list the Camunda 7 engine serves, so VanillaBP is never the narrower of the two.
Both shapes are built and tested in the blueprints
persistence-liquibase and persistence-flyway, including
the three owners of schema a workflow application usually has: VanillaBP, the workflow module and the
application itself.
This part is for an application which opted into
gruelbox (vanillabp.outbox.gruelbox.enabled, see
what the stores do differently). That store keeps its entries in
TXNO_OUTBOX, a table its own migrator creates and therefore not part of vanillabp-schema. Everybody
else can skip ahead: the default store writes into VANILLABP_PHASE_TWO_OUTBOX, which the changelog
above carries like the other two.
create-schema: false switches that migrator off together with VanillaBP's table creation, and a custom
vanillabp.outbox.jdbc.table switches it off as well, since the migrator only ever targets the default
name. Both cases leave the table to the application, and both are checked at startup like VanillaBP's
own tables: a missing table ends the boot with a message naming it and the property which would have
created it.
Nothing of that has to be copied out of gruelbox's source. Gruelbox writes its statements itself:
final var writer = new StringWriter();
DefaultPersistor
.builder()
.dialect(Dialect.POSTGRESQL_9) // the dialect matching your database
.build()
.writeSchema(writer);What comes back is the history of gruelbox's migrations for that database, from the CREATE TABLE to
the last ALTER, and applying it in that order on an empty database yields the table gruelbox
expects. TXNO_SEQUENCE is part of it and is read as soon as an entry is ordered within a topic.
TXNO_VERSION does not appear, since it is the bookkeeping of the migrator you just switched off. A
test which regenerates the statements and compares them to your migration turns a gruelbox upgrade
into a failed build instead of a failed deployment.
The Camunda 7 engine tables are the engine's business too: its JAR ships a Liquibase changelog
(org/camunda/bpm/engine/db/liquibase/camunda-changelog.xml) and plain create scripts, and
referencing those beats copying them, because the engine version decides what is correct.
Each BPMS used by the application is configured as a named adapter below vanillabp.adapters. The key is the adapter ID, naming one instance of a BPMS; its type names the BPMS adapter serving it.
An application with one BPMS adapter dependency and one workflow module needs no vanillabp.* property at all. Everything VanillaBP could derive itself, it derives:
- the adapter: the single adapter type found in the classpath becomes the one configured adapter (its ID is the adapter type) and, being the only one, 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.
Only settings the BPMS itself needs (e.g. the address of a remote cluster) have to be written, and they live in 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.
vanillabp.adapters.<id>.typeis only required for a custom adapter ID (old-bpms,c7-legacy, …) which does not match an adapter type. Custom IDs are what makes several instances of the same BPMS possible, and they can never be derived, so they always need theirtype. This default exists for applications upgrading from VanillaBP 1, where there was one adapter and its ID was its type. It is not a recommendation: an ID which says WHICH system it is (camunda8-saasnext tocamunda8-onprem,camunda7-loansnext tocamunda7-claims) is what an operator reads 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 persists it to know what belongs to which BPMS. See naming adapter ids, and never renaming them. -
A single configured adapter is the prioritized adapter.
vanillabp.prioritized-adaptersmay be omitted as long as exactly one adapter is configured. -
vanillabp.retired-adaptersnames the adapter ids this application USED to have. VanillaBP asks its stores at startup which ids they still hold waiting outbox entries or records of open tasks for, and names one which is not configured any more: it was renamed, which loses workflows, or removed too early. Naming it here says the leftovers are known, 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 it takes:
vanillabp:
prioritized-adapters:
- camunda8 # new workflows start here
- camunda7 # existing workflows complete hereThe adapter sections are derived from that list. Anything deviating from the convention has to be written down: a custom ID needs its type, and two IDs of the same type have to be distinguishable (what that means is decided by the adapter, e.g. separate databases or separate cluster endpoints).
The order in which adapters are used is defined by vanillabp.prioritized-adapters, overridable per workflow module and per single workflow, where the most specific non-empty list wins. New workflows always start in the first adapter, existing workflows are located by asking the adapters in priority order (cached after the first successful election). That is what enables BPMS migration scenarios 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: warnAdapter-specific settings (e.g. the connection to a remote BPMS) live in the SAME
section: each adapter contributes its own keys to vanillabp.adapters.<id>.* (see
your adapter's wiki for its keys). There is no separate
per-adapter namespace.
Adapter properties which vary by scope may additionally be configured per workflow
module (vanillabp.workflow-modules.<module>.adapters.<id>.<key>) and per workflow
(vanillabp.workflow-modules.<module>.workflows.<bpmnProcessId>.adapters.<id>.<key>).
The most specific configured value wins.
deployment-failure: warn only takes effect for adapters which are not first in the priority list, neither for the workflow module nor for any of its workflows. A failing deployment of an adapter being first priority anywhere always aborts booting, since new workflows could not be started otherwise.
The prioritized adapters can be overridden for a single workflow (BPMN process) of a workflow module. This is the way to migrate 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 workflow module's resources are deployed to every adapter named at the module level or at any workflow level (each such adapter receives the module's full resources, because deployment is file-granular while the election is process-granular). An adapter named only for a single workflow therefore still gets the deployment it needs to start that workflow.
-
Validation: a workflow ID configured below
workflowswhich matches no executable BPMN process of the module does not prevent booting (the BPMN may arrive later during a migration) but is reported by a startup warning naming the known BPMN process IDs. A workflow-levelprioritized-adaptersentry referencing an unknown adapter ID aborts booting, like on the other levels.
All vanillabp.* properties can be overridden by environment variables (e.g. VANILLABP_ADAPTERS_CAMUNDA8_DEPLOYMENT_FAILURE=warn). One limitation applies on all platforms: an environment variable can only override an adapter or workflow module already declared in a configuration file. It cannot introduce a new entry whose ID contains dashes or dots, because the binder cannot reconstruct the ID from the variable's name (C8_CLOUD could mean c8-cloud, c8.cloud or a nested key). Instead of silently ignoring such a variable, VanillaBP fails the startup with a message naming 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.
