Process deployment, end to end: the p1 tutorial
This is the full picture of Chenile process management: take a long
splitter/aggregator job, declare it as data, let JGen generate a runnable server + worker deployment,
fill in three methods, and run it durably over Kafka. We build it with the checked-in p1 sample — a
batch process that splits into items, executes each item on a worker, and aggregates the results.
Three repositories meet here: the runtime (chenile-process-management), the generator
(chenile-gen, JGen 2.1.34), and the sample (chenile-samples/p1). You never hand-assemble the
deployment — the generator does — so most of this page is about the two things that are yours: the process
definitions and the worker implementations.
p1Server (state, outbox, triggers, definitions REST, service
registry) and p1Worker (a headless Kafka listener plus your worker JARs) — plus per-definition
worker modules. The server never runs business logic; the worker never touches the process database.The shape of it
p1 is the brain and the muscle, split across two hosts and decoupled by one Kafka topic.
p1Server(http://localhost:8000) owns the authoritative state: the state machine, the always-on durable outbox, the trigger framework, the definitions REST service, cron, and the service registry. It dispatches work; it does not perform it.p1Worker(http://localhost:8001) is a headless host: a Kafka listener,process-delegate, and one worker JAR per process definition. It holds no process datasource and runs no outbox dispatcher.
The animation below walks a whole batch through the system — trigger → split → three items execute →
aggregate → complete. Play it, step through it, and keep it open as a map while you read the rest.
Open the schematic full-screen (handy for screen-recording).
Part 1 — Declare the processes
A deployment starts from definitions as data. p1’s processSpec keeps the runtime’s native processMap
shape: a two-level tree where batch is a non-leaf that splits into items, and item is a leaf that does
the real work.
{
"processMap": {
"batch": {
"leaf": false,
"args": "org.chenile.samples.p1.model.BatchIn",
"config": { "queue": "process-work" },
"inputFields": { "batchId": "string", "count": "int" }
},
"item": {
"parentProcessType": "batch",
"leaf": true,
"args": "org.chenile.samples.p1.model.ItemIn",
"config": { "queue": "process-work" },
"inputFields": { "itemId": "string", "index": "int" },
"outputFields": { "processed": "boolean", "itemNumber": "long" }
}
}
}
leafdecides the worker kind: a leaf gets an executor; a non-leaf gets a splitter + aggregator.parentProcessTypebuilds the tree;inputFields/outputFieldsbecome typed DTOs (BatchIn,ItemIn,ItemOutput).config.queueis the Kafka topic a definition’s work is published to (here both useprocess-work). Anything without aqueuefalls back to the blueprint’s defaultworkTopic.
Cron schedules live in a separate file (cronSpec), seeded disabled, read by JsonCrontabRepository:
[ { "name": "daily-batch", "eventName": "ProcessCreate", "cronExpression": "0 0 2 * * ?",
"timezone": "UTC", "enabled": false,
"payload": { "processDefName": "batch", "args": { "batchId": "example" } } } ]
Part 2 — Generate the deployment
The chenile-process-management JGen blueprint (2.1.35)
turns those two files into a runnable deployment. It is an aggregated blueprint: it composes minimonolith
(for the two hosts) and chenile-process-worker-set, which in
turn expands one worker module per process definition.
cd chenile-gen/jgen
jgen.sh -e oo -g chenile-process-management # writes oo/input-file.json + sample spec files
cd oo && jgen.sh -f input-file.json # generates output/p1
What comes out — the p1 reactor:
| Module | Role |
|---|---|
p1-api |
shared typed payloads + process-start contracts |
p1-server-config |
native definitions (defs.json) + cron seeds — server only |
p1-server-service |
typed Chenile HTTP services, process bootstrap, acknowledged Kafka publication; no business workers |
p1-batch-workers |
the batch worker JAR — its API owns BatchIn and AggregatedResult<ItemOutput>; its service holds the splitter + aggregator |
p1-item-workers |
the item worker JAR — its API owns ItemIn/ItemOutput; its service holds the executor |
p1-worker-listener |
a headless Chenile service that subscribes to Kafka and routes each work message to the right worker bean, with a typed payload |
p1Server, p1Worker |
the two composed minimonolith hosts (the server uses one queue-based Kafka starter) |
The p1-worker-listener is itself generated as a headless service (bluePrintName="chenile-headless-service"),
a nice illustration of blueprints composing blueprints. Its body is empty — the routing is all declarative:
@ChenileController(value = "processWork", serviceName = "_processWorkService_",
serviceSelector = "processWorkServiceSelector",
interfaceClass = WorkListenerService.class, bluePrintName = "chenile-headless-service",
registerInServiceRegistry = false)
public class ProcessWorkListener {
@EventsSubscribedTo({"process-work"})
@ChenileOperation
@BodyTypeSelector("processWorkBodyTypeSelector")
public boolean accept(String processType, String workerType,
@ChenileBody WorkListenerInput<Object> input) { return true; }
}
Each Kafka message carries processType and workerType headers. The listener’s
service selector turns them into a
bean name — batch + SPLITTER → batchSplitter, item + EXECUTOR → itemExecutor — and Chenile
calls that bean instead of the default. The body selector reads the chosen worker’s generic type, so the
payload arrives as a real BatchIn or ItemIn, not a JSON string. (_processWorkService_ is only a
fallback that fails loudly if no worker was selected.) Worker bean names are therefore routing keys:
p1 registers batchSplitter, batchAggregator and itemExecutor.
Part 3 — Implement the three workers
This is the only code you write. The generated hooks return defaults until you supply domain logic.
Each extends a typed listener base from process-delegate that handles the delivery, the typed payload, the
tenant context and the callback to the server — you only implement doStart. Your input arrives as
WorkListenerInput<T>: the typed payload plus processId, dispatchId and the definition’s execDef.
The splitter turns one BatchIn into N child payloads. makeSubProcessPayload(childInput, childType)
builds each one; the base class reports splitDone for you (or call splitPartiallyDone to submit batches as you go).
public class BatchSplitter extends SplitterWorkListener<BatchIn, Object> {
@Override protected List<SubProcessPayload> doStart(WorkListenerInput<BatchIn> input) {
List<SubProcessPayload> payloads = new ArrayList<>();
for (int i = 0; i < input.payload.count; i++)
payloads.add(makeSubProcessPayload(new ItemIn(i), "item"));
return payloads;
}
}
The executor transforms one typed input into one typed output; the base class reports doneSuccessfully
(or doneWithErrors if you throw).
public class ItemExecutor extends ExecutorWorkListener<ItemIn, ItemOutput> {
@Override protected ItemOutput doStart(WorkListenerInput<ItemIn> input) {
ItemOutput out = new ItemOutput();
out.itemNumber = (long) input.payload.index;
out.processed = true;
return out; // the generated stub returns an empty ItemOutput until you fill it in
}
}
The aggregator folds each child’s output into a consolidated result. Chenile ships
AggregatedResult<ChildOutput> (outputs + errors + counts) in process-delegate, so you don’t have to invent
an aggregate model — doStart is called once per child, progressively enhancing the result:
public class BatchAggregator
extends AggregatorWorkListener<BatchIn, AggregatedResult<ItemOutput>, ItemOutput> {
@Override protected AggregatedResult<ItemOutput> doStart(AggregatedResult<ItemOutput> out,
WorkListenerInput<BatchIn> input, ItemOutput childOutput, Process child) {
if (out == null) out = new AggregatedResult<>();
if (child.errors == null || child.errors.isEmpty()) out.completedChildren++;
else { out.failedChildren++; /* collect child.errors into out.errors */ }
if (childOutput != null) out.outputs.add(childOutput);
return out;
}
}
That’s the whole application. Everything else — dispatch, durability, retries, idempotency, tenant propagation, callbacks — is generated or lives in the runtime.
Part 4 — How one batch flows (the runtime)
Match these to the steps in the animation above. The key property: every consequence of a state change is durable, written in the same transaction as the state, and executed after commit.
- Trigger accepted. Cron (or
POST /trigger) commits a durableDISPATCH_TRIGGERcommand and returns202 Acceptedwith aLocation— before any work runs. - batch created. The dispatcher replays the trigger as
ProcessCreate;ProcessManagerpersists the rootbatchand enters the state machine (SPLIT_PENDING). - Consequence recorded. In the same transaction that saves the process, a
START_WORKER (SPLITTER)row is written to the durable outbox. - Published to Kafka. The outbox poller hands the work to the queue-based starter, which publishes a
WorkListenerInputenvelope —processId,dispatchId, the process input aspayload, andexecDef— toprocess-work. The headers carryprocessType=batch,workerType=SPLITTER, the process’s tenant asx-chenile-tenant-id, and any otherx-context headers; internal transport props are dropped. - Delivered + dispatched. p1Worker’s listener consumes it (group
p1-workers). The service selector picksbatchSplitter, the body selector types the payload asBatchIn, and yourBatchSplitterruns with the tenant restored in its context. - splitDone callback. The splitter returns three
ItemIns;process-delegatediscoversprocessServicethrough the service registry and callssplitDoneback on the server.batch→SUB_PROCESSES_PENDING. - Items execute. The server creates three
itemchildren (EXECUTING) and enqueues oneSTART_WORKER (EXECUTOR)each — out over Kafka, dispatched toItemExecutor,doneSuccessfullyback. - Parent advances.
SIGNAL_PARENTcounts each child exactly once (receipt-guarded); when all three are done and the split is finalized,batch→AGGREGATION_PENDING. - Aggregate + complete. A
START_WORKER (AGGREGATOR)runsBatchAggregator;aggregationDonedrivesbatch→PROCESSED, andEMIT_COMPLETEDpublishesProcessCompleted(chaining any successor).
Because delivery is at-least-once, a work message can arrive twice — the server’s three idempotency guards (unique enqueue key, claim-token fencing, and effect-level receipts) make that safe: a replayed child cannot be double-counted and a replayed completion cannot create duplicate successors.
When something fails, it matters what failed.
- Your worker throws. That’s a business failure: the listener reports it to the server as
splitDoneWithErrors,doneWithErrorsoraggregationDoneWithErrors(with the message, stack trace and any structuredErrorNumExceptionerrors). The process records it and endsPROCESSED_WITH_ERRORS— the message is acknowledged, not retried, because re-running the same input would just fail again. - The callback fails, or the message is malformed (no process id, no process type, a worker-type mismatch,
no tenant). That’s an infrastructure failure: the exception propagates, Chenile Kafka’s consumer retries with
a bounded policy, and exhausted messages land on the dead-letter topic
process-work.DLTinstead of blocking the partition or being lost.
There is no separate server-side Kafka publisher to coordinate — the server only publishes through the durable outbox, and the worker side owns delivery, retry and dead-lettering.
Part 5 — Run it
cd output/p1 && mvn install # KafkaBaseTest tests use Testcontainers — Docker must be running
export KAFKA_BOOTSTRAP_SERVERS=localhost:9092
export PROCESS_MANAGEMENT_API_KEY="$(openssl rand -hex 32)"
./run-server.sh # p1Server on :8000 — registers processService
# then, in another terminal:
./run-workers.sh # p1Worker on :8001 — joins consumer group p1-workers
Start Kafka first and create the process-work and process-work.DLT topics. The worker binds the topic with
chenile.kafka.consumer.topic-bindings[process-work]=process-work. Worker replicas share the p1-workers group
(override with PROCESS_WORK_CONSUMER_GROUP), so scaling the worker is just running more instances — the broker
spreads the work messages across them. Kick off a run with a trigger:
curl -X POST http://localhost:8000/trigger \
-H "X-Process-Management-Key: $PROCESS_MANAGEMENT_API_KEY" -H "x-chenile-tenant-id: default" \
-H "Content-Type: application/json" \
-d '{"eventId":"ProcessCreate","payload":{"processDefName":"batch","args":{"batchId":"b1","count":3}}}'
Part 6 — Observe it
Everything is visible through the server’s management API and the process-ui console:
GET /process-management/api/processes— the live process tree with child and successor drill-downs.GET /process-management/api/executions— trigger history (a filtered view ofDISPATCH_TRIGGERrows).GET /process-management/api/trace?processId=…— the causal graph from the first trigger through completion.- The generated server exposes the H2 console in dev, so you can watch
chenile_process_outbox,chenile_process_receiptandprocess_tablefill and drain.
Why this shape holds up
- Durable where it counts, disposable where it helps. The server keeps the state, outbox and receipts; the worker is stateless and can crash, redeploy or scale to zero without losing the orchestration record.
- Exactly-once effects over at-least-once transport. The outbox + receipts give exactly-once effects even though Kafka delivery is at-least-once; your workers only need their own dedup for external side effects.
- Scale the muscle, not the brain. Add worker replicas (same group) or route heavy process types to their own topic and pool. The one server is replicated for availability, not throughput.
- Multi-tenant throughout.
x-chenile-tenant-idrides the whole cascade — message, worker context and callback. The starter stamps each work message with its process’s tenant, and the listener refuses work that arrives without one.
Where to go next
- The runtime internals: the process management guide, the durable outbox, and the trigger framework.
- The generator: the JGen blueprint reference — the
chenile-process-management,chenile-process-worker-setandchenile-process-workersblueprints plus the composition engine. - Operating it: the management API and admin console.
- The comparison that frames the design: Chenile vs Temporal.