A Rust research engine exploring high-performance BPMN execution and Camunda 8 compatibility.
Nano BPM (nanobpmn) is a single self-contained binary that runs BPMN processes
behind a Camunda 8-compatible v2 REST API. It embeds a deterministic,
event-sourced BPMN engine (engine-core), an append-only journal for crash
durability, an SQLite-backed read model, optional multi-node Raft replication,
and a built-in web console — all in one executable with no runtime dependencies.
It is an advanced research prototype: a place to explore what a faster, smaller, faster-to-iterate-on process engine can do, while staying API-compatible with existing Camunda 8 clients and tooling.
Looking to build from source or understand the code-generation pipeline? See DEVELOPMENT.md. This README is for running and operating the distributed binary.
Nano spans three repositories (this engine, the console extension packs, and the c8ctl plugin that runs it). For the map of what lives where and how they relate, see
docs/nano-repositories.md.
Nano is built on a single design principle:
If we can tell you how to do it, and when to do it — why don't we just do it?
Anything the engine can decide correctly on its own, it decides on its own. Tuning that a human would only ever set by watching a metric and applying a rule is instead done by the engine, continuously, from that same metric. What's left for you is the one thing the engine genuinely cannot know: a business value judgement about how the service should behave at its limit.
These subsystems are automatic and on by default. Each has an environment variable only to override or disable the self-tuning behaviour — you never need to set one to get the right behaviour:
- Backpressure & the throughput ceiling. An adaptive AIMD limiter measures live
latency, sizes the in-flight watermark itself, and sheds excess creates
(
503 RESOURCE_EXHAUSTED) so a producer converges to the real drain rate. It finds the ceiling; you don't measure it. - Memory. Tiered hot-state variable spill and cold spill move the live working set to disk under pressure; an idle-purge tick compacts hot maps and returns freed arenas to the OS; completed-instance history is bounded automatically.
- Durable var-store footprint. The var-store WAL is periodically truncated so it can't ratchet up on disk under a sustained large-payload load.
- Cluster job drain (multi-node). Backlog-weighted activation fairness steers
each worker's lease budget toward the nodes that actually have jobs — the deepest
node drains fastest — instead of strict local-first. On by default (
stage2). - Cluster create placement (multi-node). Load-aware placement (smooth weighted
round-robin over gossiped per-node load) steers new instances toward nodes with
real spare capacity, and every node self-protects: a saturated owner sheds a
forwarded create back to the ingress node, which reroutes it to a node with
headroom. The client only sees backpressure under genuine cluster-wide
saturation. On by default (
balanced). See ADR 0014.
All of the above are no-ops on a single node and where load is evenly balanced, so the defaults are byte-identical to the historical behaviour exactly where they can't help — and strictly better where they can.
There is exactly one knob that encodes a business decision, because it is the one thing the engine cannot infer — it depends on what your service promises. When the system reaches its capacity ceiling, which SLA do you want?
Do we admit all-comers to the restaurant and let them know the meal-serving time is getting longer — or ask newcomers to come back later so we can guarantee the patrons already seated get their meals as fast as possible?
That is NANOBPMN_SLA_MODE:
latency(default) — "seat fewer, serve fast." Preserve end-to-end speed by shedding admission at the ceiling. A time-to-complete SLA.admission— "seat everyone; the backlog and the wait grow with demand." Drop the proactive active-backlog governor and run the engine at its true drain ceiling (~48% more throughput in test). The AIMD concurrency limiter stays armed as an engine-overload guard — it measurably tightens the tail at no throughput cost — but it does not bound the accumulated backlog: under sustained overload the backlog grows until the memory-safety rails shed. You get higher throughput thanlatencymode and a much looser tail. A start-every-process SLA.
In both modes the memory-safety rails still guard against OOM, and the AIMD limiter
stays armed as an engine-overload guard. What differs is the backlog bound:
latency mode's active-backlog governor holds the backlog tight (so the tail stays
short), whereas admission mode drops it and lets the backlog grow with demand up to
the memory-safety rails — trading a much longer tail for higher throughput.
admission accepts looser latency and a larger backlog, never a crash. This is the
only behavioural policy you choose, and it is
switchable at runtime, cluster-wide
(flip it on one node and it propagates to the rest).
The remaining environment variables don't tune behaviour under load — they declare the shape and durability of your deployment, which are inherent choices for any distributed, durable system:
- Topology — how many
NANOBPMN_NODES,NANOBPMN_PARTITIONS, and the replication factorNANOBPMN_RF. This is how big and how fault-tolerant, not how to behave. - Durability tier —
NANOBPMN_DURABILITY/NANOBPMN_REPLICATION: how much of a just-acked tail you're willing to lose on an ungraceful leader loss (zero, by default). Every durable distributed engine makes you state this; it's a data-safety guarantee, not an envelope-edge tuning. See ADR 0003.
So: the engine self-optimizes with zero technical configuration, the deployment
knobs describe what you're running, and the single business decision —
NANOBPMN_SLA_MODE — describes what you promise. Full reference in
Cluster configuration and Cluster tuning.
The easiest way to run and manage Nano BPM — single node or a whole cluster — is
the c8ctl CLI with the
c8ctl-plugin-nano plugin. The
plugin ships a prebuilt Nano BPM binary for your platform (installed
automatically as an npm optionalDependency), so there is nothing to compile.
# Load the plugin (installs the matching prebuilt binary for your OS/arch)
c8ctl load plugin c8ctl-plugin-nano
# Start a single-node cluster on port 8080
c8ctl nano start
# Start a 3-node cluster (ports 8080, 8081, 8082)
c8ctl nano start 3
# Start a 3-node Raft-replicated cluster (RF=3 enables Raft automatically)
c8ctl nano start 3 --rf 3
# Show cluster status and per-node health (queries each node's /v2/topology)
c8ctl nano status
# Tail a node's log (node ids are 0-indexed, so a single node is node 0)
c8ctl nano logs 0 --follow
# Simulate a node failing and recovering
c8ctl nano pause 0
c8ctl nano resume 0
# Stop the cluster (engine data retained); add --purge to delete engine data
c8ctl nano stopc8ctl nano keeps your authoring assets (BPMN models and worker code) in a
shared workspace that survives stop/clean, separate from the ephemeral
per-node engine data. It also wires every node's environment for you (ports,
node ids, partitions, replication, data dirs). See the
plugin README for the full command
and flag reference, including trace capture (--capture).
Once a cluster is up, point any Camunda 8 v2 REST client (or the
@nanobpmn/sdk Falcon client) at
http://127.0.0.1:8080/v2, and open the console at
http://127.0.0.1:8080/console.
You can also run the binary directly (the same one the plugin installs), configuring it entirely through environment variables:
NANOBPMN_DATA_DIR=/var/lib/nanobpmn PORT=8080 \
./nanobpm-gateway-rest-serverWhen started, the gateway prints the human-facing URLs:
Nano BPM is up:
Landing page http://127.0.0.1:8080/
Web console http://127.0.0.1:8080/console
API reference http://127.0.0.1:8080/swagger
REST API http://127.0.0.1:8080/v2
Metrics http://127.0.0.1:8080/metrics
Common configuration:
| Variable | Effect |
|---|---|
PORT=<n> |
HTTP listen port (default 8080). |
NANOBPMN_DATA_DIR=<dir> |
Durable event-log + read-model directory. Without it the server runs fully in-memory (ephemeral). |
DEBUG_REST=1 |
Log every REST request/response (method, URI, status, latency, body preview). Leave off in production. |
NANO_CONSOLE_TERMINAL |
Controls the IDE's integrated terminal (a full shell in the project directory). Enable it in the console (Config → Server → Integrated terminal); the setting persists in <NANOBPMN_DATA_DIR>/console-settings.json. This env var is a hard lock: set it to 0/false/off to force the terminal off and prevent the console from enabling it (for shared / network-exposed servers). Set to 1/true/on to default it on. Only ever reachable from loopback (local peer IP + loopback Host), since a shell is arbitrary code execution. Requires the console feature. |
INFO rest: --> POST /v2/process-instances [53 bytes] {"processDefinitionId":"demo","tenantId":"<default>"}
INFO rest: <-- POST /v2/process-instances 200 OK (39.7ms) [180 bytes] {"processInstanceKey":"3", …}
The full set of tuning variables (durability, memory, partitions, clustering) is documented in the sections below.
Nano BPM serves the Camunda 8 Orchestration Cluster v2 REST API. The whole
API surface is routable; operations not yet wired into the engine respond with
501 Not Implemented. For the honest, element-by-element boundary of what
executes and what is served — and where the edges are — see the
Camunda compatibility subset. The following
are backed by the embedded engine-core BPMN engine:
POST /v2/deployments(createDeployment) parses the uploaded BPMN 2.0 XML resources and deploys them, assigning each process a key and a per-id version. Deployment is idempotent: redeploying a process that is byte-for-byte identical to the current latest version of the same id reuses that version (no new key, no version bump, nothing journaled), mirroring Zeebe; a changed model deploys as the next version.POST /v2/process-instances(createProcessInstance, byprocessDefinitionIdorprocessDefinitionKey) starts a real instance and returns its engine-assigned key. Variables supplied on the request seed the root scope. WithawaitCompletion: truethe request blocks (off the engine write lock) until the instance reaches a terminal state orrequestTimeoutms elapse (default 5s). The response carries aprocessCompletedflag: whentrue, the returnedvariables(optionally narrowed byfetchVariables) are the authoritative final result; whenfalse, the instance is still running. Deviation from Camunda: on timeout nanobpmn returns200withprocessCompleted: falseand theprocessInstanceKey(so the caller can poll) rather than Camunda's504.POST /v2/jobs/activation(activateJobs) activates available jobs of a type, locking each to the worker untilnow + timeout; supports Camunda-style long-polling viarequestTimeout(a waiting request wakes as soon as a job becomes available).POST /v2/jobs/{jobKey}/completion(completeJob) completes a job and resumes the token, merging any returned variables into the instance (so worker output drives downstream gateway routing). A job must have been activated first; completing an un-activated job returns409. Completion is by key alone (no worker check), so a slow worker whose lock expired can still complete the job — first completion wins.POST /v2/jobs/{jobKey}/failure(failJob) sets a job's remaining retries. With retries left the job returns to the activatable pool; with none an incident is raised and the job parks (no longer activatable or completable).POST /v2/jobs/{jobKey}/error(throwError) raises a business error from a job. If the job's service task has an error boundary event with a matchingerrorCode, the task is interrupted and the boundary's outgoing path runs (e.g. a refund/compensation flow); otherwise the error propagates up any enclosing embedded sub-process to a sub-process error boundary (which terminates the whole sub-process scope), and if still uncaught an incident is raised. The job is consumed either way. Returns404for an unknown or un-activated job and409if the job is no longer active.PATCH /v2/jobs/{jobKey}(updateJob) applies the changeset'sretriesto a job — used to recover a job parked on a no-retries incident (timeout updates are not modelled).POST /v2/incidents/{incidentKey}/resolution(resolveIncident) resolves an incident by retrying the failed work, not just clearing the record. A job-incident returns the parked job (which must have retries again) to the activatable pool — recovery loop:failJob(0) →updateJob(retries) →resolveIncident→ re-activate →completeJob. A gateway incident re-evaluates the gateway; an uncaught-error incident re-creates the service-task job. If the retry fails again, a fresh incident is raised. Incidents now carry a realcreationTime(the server feeds the engine its clock at command time). Resolving accepts an optionaloperationReference(recorded on the retained record for audit) and returns409if the incident is already resolved.PUT /v2/element-instances/{elementInstanceKey}/variables(createElementInstanceVariables) merges variables into a scope (the key may be a process instance or an active element instance). Use it to correct the data behind a gateway/condition incident, thenresolveIncidentre-evaluates and the token proceeds.localis accepted but has no effect (single scope).POST /v2/messages/publication(publishMessage) andPOST /v2/messages/correlation(correlateMessage) deliver a message to any open subscription whose name and correlation key match, releasing a message intermediate catch event's token, interrupting an activity via an interrupting message boundary event, or spawning a parallel token via a non-interrupting message boundary event while the activity keeps running (such boundary events attach to a service task or an embedded sub-process — an interrupting one on a sub-process tears down its whole inner scope) (merging the message's variables into the instance first). Messages are not buffered (no TTL/dedup): with no match the message is dropped.publishMessagealways returns200with the mintedmessageKey;correlateMessagereturns404when nothing correlates (and otherwise200with the first correlatedprocessInstanceKey). A message start event also correlates here: a matchingcorrelateMessagecreates a new instance (seeded with the message's variables) and returns itsprocessInstanceKey.- Event-triggered instance creation. Deploying a process whose start event is
a message start event opens a process-level subscription (a matching
correlateMessagecreates an instance), and a timer start event arms a process-level timer fired by the background tick — a one-shottimeDurationruns once, a recurringtimeCycle(R/PT…) creates an instance every interval. Both are journaled, so the subscription/schedule survives a restart.
Read endpoints make engine state observable:
GET /v2/process-instances/{processInstanceKey}(getProcessInstance) — reports state (ACTIVE/COMPLETED) andhasIncident.GET /v2/incidents/{incidentKey}(getIncident) andPOST /v2/incidents/search(searchIncidents) — expose incidents (active and resolved, since resolved records are retained as an audit trail; each reports itsstate,ACTIVEorRESOLVED), including theincidentKeyneeded to resolve them.errorTypereflects the cause (JOB_NO_RETRIES,CONDITION_ERROR,UNHANDLED_ERROR_EVENT).POST /v2/process-instances/search(searchProcessInstances) andPOST /v2/jobs/search(searchJobs) round out the read surface.POST /v2/variables/search(searchVariables) andGET /v2/variables/{variableKey}(getVariable) — expose instance variables. nano keeps a single instance-level scope, so every variable'sscopeKeyequals itsprocessInstanceKey. Values are reported as serialized JSON (a stringtextas"text", numbers/booleans bare);searchVariablestruncates long values unlesstruncateValues=falseand flagsisTruncated. Variable keys are assigned by the read model (the engine does not mint them).
All search endpoints implement the full v2 query contract:
- Filters use the advanced operator algebra —
$eq,$neq,$exists,$in,$notIn, and$like(with*/?wildcards) — in addition to plain scalar equality. Examples:state: {$in: ["FAILED", "ERROR_THROWN"]},type: {$like: "pay*"},processInstanceKey: {$exists: true}. - Sort accepts multiple
{field, order}clauses applied in order, with the entity key as a deterministic final tiebreak. - Pagination supports all four request shapes:
{limit}, offset{from, limit}, forward cursor{after, limit}, and backward cursor{before, limit}. Responses carrytotalItemsplusstartCursor/endCursor(opaque, padding-free base64 of the entity key) for stable cursor walks.
Timestamps are reported as the Unix epoch since the engine is clock-free.
A demo process (processDefinitionId: "demo", a single service task) is
pre-deployed at server startup, but you can also deploy your own .bpmn files
through the deployment endpoint. Engine and parse errors map to real status
codes (400/404/409); everything else is still 501.
The engine is in-memory but event-sourced: every command returns the complete,
ordered list of events it produced, and replaying those events over a fresh
state reconstructs it exactly. The server turns that into crash durability with
an append-only journal. Set NANOBPMN_JOURNAL to a file path and every
durable command's events are appended (newline-delimited JSON) and flushed
before the response returns; on startup the log is replayed through
Engine::replay, which also advances the key generator past every key the log
assigned so post-recovery commands never collide with replayed ones. Without the
env var the server runs purely in memory (ephemeral).
NANOBPMN_JOURNAL=./nanobpmn.journal PORT=8099 ./nanobpm-gateway-rest-serverJob activation locks are intentionally not journaled — they are volatile lease state. A restart forfeits every lock, returning uncompleted jobs to the activatable pool, so a worker simply re-activates after recovery. Everything durable (deployments, instances, element progress, jobs, incidents, variables, completion, armed timers) survives.
| Variable | Effect |
|---|---|
NANOBPMN_DATA_DIR=<dir> |
Co-locates both under one directory: <dir>/journal.jsonl + <dir>/read-model.sqlite (created if absent). |
NANOBPMN_JOURNAL=<file> |
Back-compat: selects the journal file; the database is NANOBPMN_READ_DB if set, else a sibling read-model.sqlite. |
| (neither set) | Fully in-memory: an ephemeral journal and a :memory: read store. Nothing is persisted. |
The engine reads no wall clock; the host drives time in. The server runs a
single background task (every 500 ms) that feeds now into the engine via two
ticks: TriggerTimers fires every due timer intermediate catch event,
timer boundary event (interrupting or non-interrupting, on a service task or
embedded sub-process; a timeCycle non-interrupting boundary re-arms for the
next interval on each fire) and timer start
event (durable — journaled), and ExpireJobs
releases activation locks past their deadline (volatile — not journaled). When a timer fires it may unblock
downstream work, so the tick wakes any long-polling activateJobs. A timer
parked before a restart is recovered by replay and fired by the first due tick
afterwards.
The server runs on a multi-threaded Tokio runtime (one worker per core), so
connection handling, HTTP parsing and JSON (de)serialization already spread
across cores. The engine itself is a single writer, so it sits behind a
RwLock: mutating operations (create instance, complete/fail job, deploy, job
activation, the background tick) take the write lock and are serialized,
while the read-only search*/get*/topology projections take the read
lock and therefore run concurrently across cores. The lock is never held
across an .await, and critical sections are short. A write enqueues its events
to the journal writer thread under the write lock (preserving command order),
then releases the lock and .awaits durability outside it, so disk I/O no longer
serializes behind the engine lock; read-heavy load scales with the number of
cores.
The journal is owned by a dedicated nanobpmn-journal-writer thread. Each
mutating command serializes its events and hands them to the writer over an
ordered channel, receiving a Commit handle. The writer batches every queued
request into a single write_all followed by one sync_all (fsync), then
acks each batched command — amortizing the fsync cost across concurrent writes
(group commit). A handler only returns its success status after its
Commit resolves, i.e. after the events are durably on disk, so a 200/204
means "persisted and will survive a crash". If a journal write ever fails the
writer aborts the process rather than serve state that outran the durable log;
on restart the log replays to re-derive consistent state.
The engine's hot state holds only what execution needs. If every completed process instance, job and incident stayed there forever, resident memory would grow without bound under sustained load (and never come back down). nanobpmn follows the Camunda 8 / Operate split — a command side and a separate read side — collapsed into a single binary:
- Command side — the engine + journal. It runs in-memory and is the single writer. Once an instance's history is durably recorded in the read model, the engine evicts the completed instance and everything it owns (jobs, timers, subscriptions, incidents) from hot state, so the footprint tracks in-flight work rather than all history.
- Read side — an embedded SQLite read store. A background
nanobpmn-exporterthread streams the journal's event log into it (in command order), projecting events into denormalizedprocess_definitions/process_instances/jobs/incidents/variablestables. Everysearch*/get*query is served from SQLite, never from hot engine state — which is what makes eviction possible.
The read model is a pure derived projection of the journal, so it needs no
durability of its own: on boot the server replays any journal events the store
has not yet projected (tracked by an exported_position cursor; a fresh or
schema-mismatched store rebuilds from scratch), then evicts completed instances
from the recovered hot state.
Because the exporter is asynchronous, reads are eventually consistent: a
search/get issued in the instant after a write may briefly not observe it
(typically sub-millisecond). This mirrors Camunda 8's Operate read channel.
Activation locks are volatile and not journaled, so a
searchJobsresult reflects durable state: an activated job shows asCREATEDwithout a worker or deadline. This is deliberate — the read model reports what survives a crash.
Load arrives in bursts: a flood of createProcessInstances grows the hot-state
maps and 50 KB-class variable payloads, which are then freed as instances
complete and are evicted. Nano BPM keeps that freed memory from being pinned, and
bounds the live peak of large backlogs.
Rust maps never shrink on removal, and the default system allocator hoards freed pages, so an idle server would otherwise pin its peak resident footprint long after the work is gone. Nano BPM addresses both:
- It uses jemalloc as the global allocator (vendored, built from source — the binary stays self-contained). jemalloc returns unused pages to the OS on a decay schedule, driven by a background thread on Linux. (On Windows/MSVC the system allocator is used instead.)
- An idle-purge tick closes the gap on platforms with no jemalloc background
thread (macOS) and makes reclamation prompt everywhere: when the engine goes
quiescent after a burst, it
shrink_to_fits the hot-state maps and forces jemalloc to purge every arena, returning the freed memory to the OS immediately. It fires once per active→idle transition, never while work is flowing, so it adds no steady-state cost. (In a local run, a 4 000-instance create+complete burst's idle tick loggedreturned 156.9 MiB to the OS (214.4 -> 57.5 MiB resident).)
| Variable | Effect |
|---|---|
NANOBPMN_IDLE_PURGE_MS=<n> |
Quiescence (ms) the server must be idle before it compacts hot state and returns freed memory to the OS. Default 5000; 0 disables the idle-purge tick. |
NANOBPMN_HISTORY_MAX_INSTANCES=<n> |
Caps how many completed/terminated instances the read model retains (with their variables, jobs and incidents); the oldest beyond the cap are evicted continuously on the exporter thread. Active instances are never evicted. Default 0 = unbounded history. Set it to bound read-model memory to the working set. |
The engine's hot state lives in RAM for speed, but two workloads make an unbounded
resident footprint a problem: a large active backlog (many instances parked on a
job, each carrying a 50 KB-class variable payload) and a large dormant backlog
(many long-lived instances parked on a timer or message, idle for minutes to days).
nanobpmn pages both classes out to disk and rehydrates them on demand, mirroring how
Camunda 8 / Zeebe back hot state with RocksDB. Both tiers reuse one SQLite store
(<data-dir>/var-spill.sqlite) and are derived caches of the durable journal — a
lost spill blob is reconstructable, so the store runs synchronous=NORMAL.
- Variable spill sheds just the variables of instances parked on a job once the
resident parked-backlog exceeds a hot budget, and rehydrates them on
activateJobs(the moment a worker needs them). It targets the high-throughput flood case and leaves the small control-state maps resident. It deliberately skips instances holding a timer or open message subscription, since those resume without an activation seam. - Cold spill evicts whole dormant instances — control state, jobs, timers, subscriptions, variables — when resident RAM crosses a high-water mark, keeping only a slim resident routing index (job keys, message correlation keys, timer due-times) so an off-heap instance can still be found. It rehydrates an instance the instant an event targets it: a worker poll for its job type, a command addressing it by key, a correlating message, or its timer falling due.
Both tiers are on by default in persistent mode (a data directory is configured) and off for fully in-memory runs.
| Variable | Effect |
|---|---|
NANOBPMN_VAR_SPILL=<on|off> |
Force variable spill on/off. Unset: on iff a persistent data path exists. |
NANOBPMN_VAR_SPILL_BUDGET=<n> |
Resident parked-instance budget before variables spill. Default 512. |
NANOBPMN_COLD_SPILL=<on|off> |
Force cold spill on/off. Unset: on iff a persistent data path exists. |
NANOBPMN_COLD_SPILL_MB=<n> |
High-water resident RAM (MiB) above which dormant instances are evicted to disk. Default 384; low-water = 7/8 of high. |
Following Zeebe, a node may run several partitions, each its own single-writer engine thread + journal. Partitioning multiplies the single-writer throughput ceiling (each partition fsyncs and applies independently) while keeping every command serialized within its partition.
- Default is one partition (
NANOBPMN_PARTITIONS=1), which preserves the historical behaviour exactly: one engine thread, onejournal.jsonl, keys1, 2, 3, …. - Set
NANOBPMN_PARTITIONS=<n>to runnpartitions (0-based ids0…n-1). Keys embed their owning partition in their high bits, so a command targeting an existing key routes to exactly one partition, while a freshcreateProcessInstanceis balanced round-robin across partitions. An instance lives on its creating partition for life. - Queries,
awaitCompletion, and the read model are global — answered from a single shared projection fed by all partitions, so multi-partition is transparent to clients.
| Variable | Effect |
|---|---|
NANOBPMN_PARTITIONS=<n> |
Number of single-writer partitions. Default 1. Clamped to [1, 8192]. Changing this requires fresh data directories (the journal layout differs). |
A deployment is one or more nodes (processes). Every node is both a broker
(owning a subset of partitions) and a gateway (accepts client connections for
the whole cluster, forwarding operations it does not own to the node that does).
Partition→node placement is deterministic (partition_id % num_nodes), so every
node computes the same ownership map from static config with no coordinator.
Single-node (the default, NANOBPMN_NODES unset) is byte-for-byte the historical
behaviour.
The c8ctl plugin wires all of the variables below for you —
c8ctl nano start 3 --rf 3brings up a 3-node, Raft-replicated cluster on localhost with no manual env-var configuration.
Two independent axes:
- Distribution (
NANOBPMN_NODES/NANOBPMN_NODE_ID): spread partitions across nodes for throughput. Each partition still lives on exactly one node. - Replication (
NANOBPMN_RAFT=on+NANOBPMN_RF=<k>): replicate each partition acrossknodes as a per-partition Raft group, for durability and failover.
With NANOBPMN_RF=k, each partition's replica set is k consecutive nodes
([owner, owner+1, …]), the first being its initial leader, and writes commit
through Raft. RF is clamped to [1, num_nodes]. A k-voter Raft group
commits only with a quorum of ⌊k/2⌋+1 replicas and therefore tolerates ⌊(k−1)/2⌋
failures:
| Nodes (RF = nodes) | Quorum | Faults tolerated | On one node loss |
|---|---|---|---|
| 1 | 1 | 0 | total outage |
| 2 | 2 | 0 | total write outage (see warning) |
| 3 | 2 | 1 | stays up — sub-second leadership blip |
| 5 | 3 | 2 | stays up |
Raft deployments use odd node counts. The first size that survives a failure is 3 (quorum 2, tolerates 1).
⚠️ Two-node clusters are a trap. A 2-nodeRF=2group has quorum 2 — both nodes are required to commit — so it tolerates zero faults, the same as a single node, while doubling the number of machines whose failure halts all writes. It buys durability only, not availability. Use 3+ for fault tolerance.
nanobpmn is CP (consistency over availability): a partition without a quorum refuses to commit rather than diverge — so there is no split-brain and no data loss, ever. Behaviour depends on which side of the cut you are on:
- Majority side (e.g. lose 1 of 3,
RF=3). The survivors hold quorum (2/3), re-elect a new leader for the lost node's partitions within the election timeout (~sub-second), and keep serving. In-flight writes forwarded to the now-dead leader fail fast and retry on the new leader. Measured: throughput through a node loss holds at ~98% of baseline with a sub-second p99 blip, then full recovery on rejoin — no data loss. - Minority side (a single node taken off the network). That node can reach no quorum for any partition, so it becomes write-unavailable: every create/complete/activate fails fast. Reads are still served from its local applied state — consistent but frozen/stale. No writes it attempted while isolated are ever acknowledged or retained.
- RF=1 (no replication, the default). Each partition is single-homed. Losing a node keeps the survivor fully serving its own partitions (partition-level fault isolation), but the dead node's partitions go offline until it returns.
A returning node reconnects, catches up via Raft AppendEntries (or an
InstallSnapshot if it fell far behind), discards any uncommitted entries it
proposed while isolated, and resumes as a follower. Membership is not changed on
a transient outage, so no operator action is needed.
| Variable | Effect |
|---|---|
NANOBPMN_NODES=<url,url,…> |
Comma-separated node base URLs, index = node id (e.g. http://10.0.0.1:8080,http://10.0.0.2:8080). Unset (or one entry) ⇒ single node. |
NANOBPMN_NODE_ID=<i> |
This node's id (index into NANOBPMN_NODES). Default 0. |
NANOBPMN_RAFT=on |
Enable per-partition Raft replication. Off ⇒ the single-homed, byte-identical path. |
NANOBPMN_RF=<k> |
Replication factor: nodes per partition. Default 1. Clamped to [1, num_nodes]. Use an odd node count with RF=num_nodes for fault tolerance. |
NANOBPMN_REPLICATE_ACTIVATION=<mode> |
How the job-activation lock is handled under Raft (RF>1). Default under quorum ⇒ auto: a zero-config alias for leader-local + soft digest (identical to digest) — validated healthy across the whole payload/throughput range (2,400/s @ 50 KB, ~36k/s @ negligible payload; see PERFORMANCE.md). 1/quorum ⇒ always replicated (strict lease; a per-activation quorum commit that collapses throughput at scale — unsafe at any non-trivial load, kept only for parity/testing). 0/off ⇒ leader-local (lease in the leader's RAM only; immediate redelivery on failover). digest ⇒ leader-local plus a best-effort lease broadcast that narrows the failover redelivery window. All modes are at-least-once. See docs/adr/0002-leader-local-activation-and-lease-digest.md. |
NANOBPMN_REPLICATION=<tier> |
Replication durability tier under Raft (RF>1). Default ⇒ quorum (acks after a majority commits + applies; zero data loss on node loss). leader-durable (acks=1) ⇒ the leader acks after its own local durable append+apply and ships to learners asynchronously (lowest latency; a just-acked tail can be lost on ungraceful leader loss, bounded — never divergent). See docs/adr/0003-write-path-durability-tiers.md. |
NANOBPMN_RAFT_HEARTBEAT_MS / _ELECTION_MIN_MS / _ELECTION_MAX_MS |
Leader heartbeat (default 250) and randomized election window (defaults 500/1000). |
NANOBPMN_RAFT_SNAPSHOT_LOGS=<n> |
Snapshot every n applied entries (log compaction). Default 5000. |
NANOBPMN_ACTIVATION_FAIRNESS=off|1|2 |
Fairness-aware job-activation routing across nodes. Default 2 (self-optimizing: caps each source by its live backlog, steering a worker's lease budget toward where the jobs are). 1 ⇒ rotation+quota only; off ⇒ strict local-first (historical). No-op on a single node. See docs/adr/0001-cluster-job-activation-fairness.md. |
NANOBPMN_CREATE_PLACEMENT=off|protect|balanced |
Cluster create-placement protection & load-awareness (RF>1). Default balanced (self-optimizing): load-aware weighted placement (smooth weighted round-robin over peer-gossiped load) steers creates toward nodes with real spare capacity, plus protect — a saturated owner sheds a forwarded create back to the ingress node, which reroutes to an owner with headroom (only 503s the client under global saturation). protect ⇒ self-protection without load-weighting; off ⇒ blind round-robin (historical). Never risks a duplicate instance (reroute fires only on a proven-not-applied shed). No-op on a single node (byte-identical). See docs/adr/0014-create-placement-protection-and-load-awareness.md. |
NANOBPMN_CREATE_PLACEMENT_GOSSIP_MS=<ms> |
Peer create-load gossip interval for NANOBPMN_CREATE_PLACEMENT=balanced. Default 500. No effect in off/protect or single-node. |
See docs/distributed-scaling-design.md for
the full design rationale.
Alongside the REST API, the server exposes a single bidirectional WebSocket
at GET /falcon that multiplexes the whole client lifecycle — process
creation and the full job lifecycle — onto one persistent, credit-coordinated
socket. It funnels to the same engine command path as the REST handlers, so it is
purely a more efficient ingress: no per-request connection setup, no long-poll for
jobs, and flow control by credits instead of 429/503 + client retry.
Connect with an optional worker query parameter
(/falcon?worker=my-worker). Frames are JSON text frames, each a
tagged union with a camelCase "type". On connect the server sends a welcome
(initial submission window + heartbeat cadence) followed by a submissionCredits
grant. Idle sockets exchange heartbeat frames every 15 s.
The stream carries two credit lanes over the one engine thread:
- Job push (demand/pull). A client
subscribes to a job type with a credit count; a single server-side dispatcher leases jobs round-robin across all subscribers and pushesjobframes while credits remain, topping demand back up viajobCredits. Lease expiry is the at-least-once guarantee: a job pushed to a worker that never completes it is reclaimed by the periodic lock-expiry tick, so a dropped socket needs no special handling. - Submission (request/response).
createInstanceis metered by a submission-credit window fed from the engine's processing headroom: under saturation the server simply withholds credits and the client stalls intake — no503, no retry storm. Job completions (completeJob/failJob/throwError) flow unmetered — draining backlog must never be throttled.
Client → server frames: subscribe, jobCredits, createInstance,
completeJob, failJob, throwError, awaitInstance, heartbeat.
Server → client frames: welcome, job, commandResult, instanceCompleted,
submissionCredits, pressure, heartbeat.
Await-completion and recovery. createInstance may set
awaitCompletion: true; rather than holding the request, the server returns the
commandResult (with the processInstanceKey) immediately and later emits an
async instanceCompleted frame, correlated by the create's corr. If the socket
drops before that arrives, the client recovers by sending an awaitInstance
frame on the new connection with the persisted processInstanceKey. Because the
read model is durable history, an already-terminal instance resolves
immediately, so awaitInstance doubles as a completion poll.
Per-connection ordering vs. throughput. A single connection's frames are processed in arrival order, each engine command awaited inline — so successive
createInstances on one socket serialize at journal fsync latency. Throughput comes from concurrency across connections: the group-commit journal writer batches many connections' appends into a single fsync. Spread submission load over multiple sockets rather than pipelining one.
Recommended worker topology. Because ordering is per-socket and throughput scales with concurrent sockets:
- One stream per job-type worker — each gets its own demand-credit lane and reader task, so delivery for different types proceeds in parallel.
- Separate creation from work — use a distinct socket (or a small pool) for
createInstancesubmission so a burst of creates can't head-of-line-block job completions. For high create rates, fan out across a pool of submission sockets — that is what lets the group-commit writer batch their appends. - Rule of thumb for 10 job types: ~10 worker sockets (one per type) plus a small submission pool (e.g. 2–8 sockets sized to your create rate).
| Variable | Effect |
|---|---|
NANOBPMN_STREAM_SUBMISSION_WINDOW=<n> |
Per-connection create-submission window (default 256). |
Stream durability: ack-before-fsync pipelining. To maximize throughput, job
lifecycle commands (completeJob / failJob / throwError) use pipelined
commits: the server applies the command to the engine (establishing order),
then replies 200 immediately while fsync completes asynchronously (~5 ms
later). This batches many connections' completions into one group-commit fsync,
measured at 4× higher throughput (2280 vs 572 writes/s) than awaiting fsync
inline. The trade-off: a crash in the ~5 ms window re-activates the job after
restart (its lock expires) — preserving at-least-once semantics (handlers must
be idempotent, the standard BPMN worker contract). The REST API
/jobs/{key}/completion endpoint still awaits fsync before replying; only the
Falcon pipelines.
See docs/falcon-design.md for the full
design rationale,
docs/falcon.asyncapi.yaml for the
AsyncAPI 3.1 description of every frame, and
server/tests/falcon_e2e.rs for runnable examples.
clients/node-stream publishes @nanobpmn/sdk, a
companion to @camunda8/orchestration-cluster-api that adds a typed
Falcon client and a streaming job worker. The worker auto-detects the
backend: it uses the Falcon protocol against nanobpmn and falls back to Camunda
REST polling against a Camunda gateway, so the same handler code serves both.
See clients/node-stream/README.md.
The distributed binary includes a built-in web console (behind the console
Cargo feature, which the c8ctl-shipped binaries enable). Start a cluster and open
http://127.0.0.1:8080/console. It serves a single-page app at /console and a
JSON API under /console/api/*. The root / serves a small self-contained
landing page, and /swagger serves an offline Swagger UI with the OpenAPI
spec bundled in (nothing fetched from a CDN).
The console ships in two profiles and the navigation adapts to each:
- Studio (the default authoring build) shows Projects, Extensions, Topology, Metrics, Explorer, Traces, and Workers.
- Observe (the operator build) drops the two authoring tabs and shows only Topology, Metrics, Explorer, Traces, and Workers.
Two more views — Config (appearance, theme packs, and server settings) and Credits — are reachable by route but do not live on the navigation rail. Each tab is described below.
Cluster/partition/Raft overview with live per-node health: each peer's
GET /v2/topology is probed (concurrently, on a 5 s cadence) to show
reachability, gateway version, and round-trip latency.
A live performance dashboard (process starts/s, jobs/s, active processes,
connected clients, commit pipeline depth, journal/fsync means, writer duty cycle,
resident memory) with inline sparklines, derived client-side from the gateway's
Prometheus surface (/metrics). In a multi-node cluster it also shows a
per-node breakdown by probing each peer's GET /console/api/metrics.
Ceiling LEDs and worker-provisioning hints. Like the LED on a rack
compressor/limiter that lights when the limiter engages (see
ADR 0013), /metrics
exposes when a node is pressed against a capacity ceiling:
nanobpm_ceiling_active{ceiling="throughput"|"memory"}—1while at that ceiling, else0.throughput= create-processing concurrency / active-backlog limit;memory= the always-in-circuit memory-safety rails (create-queue depth, exporter saturation, in-flight pipeline bytes, resident-memory watermark).nanobpm_ceiling_hits_total{ceiling}— rising-edge count of how often each ceiling engaged (a peak-hold on the gain-reduction meter).nanobpm_job_type_activatable{job_type}/nanobpm_job_type_workers{job_type}/nanobpm_job_type_starved{job_type}— waiting jobs, subscribed workers, and a starvation hint (1when jobs wait with no worker to drain them) so under-provisioned job types are visible at a glance.
All are published ~1 Hz off the hot path (relaxed atomic reads + cheap low-priority scans), so scraping them never touches the create/complete path.
Nano Studio (the Studio tab) is the home of Nano's Rapid Application Development environment and replaces the old standalone "Modeler" tab. A project is a self-contained, runnable Node application directory (see Workspace vs cluster data); the Studio view is a tile gallery with a New project template picker, and opening a project drops you into its workspace. First-party project templates and example apps are themselves shipped as extension packs.
Inside a project you author four first-class file kinds, each with a dedicated editor:
- Models (
.bpmn, inresources/processes/) — a bpmn-js editor. - Decisions (
.dmn, inresources/decisions/) — a dmn-js editor. DMN is executed natively by the engine's Rust decision engine (no JVM), so abusinessRuleTaskwith azeebe:calledDecisionevaluates on the cluster just like any other element. - Forms (
.form, inresources/forms/) — a form editor with live preview. - Pages (
.page.json, inpages/) — a Page Composer for assembling data grids and page layouts over the project's domain.
From a BPMN file you can Deploy the model to the engine (idempotent), Start
instance (optionally supplying initial variables as JSON) once the saved XML
matches what is deployed, and Test it entirely in the browser. At the project
level a Run / Stop / Compile / Configure / Export toolbar runs main.ts
under Node, compiles the project, or zips the whole project — none of
which touches the engine data dir.
Test executes a model entirely in the browser, with no cluster round-trip and
fully offline. It is powered by μ-nano ("micro-nano") — a compact WebAssembly
build of the very same Rust engine-core that the cluster runs, aggressively
size-optimized (opt-level="z", fat LTO, single codegen unit, panic="abort",
wasm-opt -Oz) to about 0.5 MB, and served gzip-compressed so it lands in
~0.2 MB over the wire. It is compiled for wasm32 and loaded straight into
the page. Because it is the production engine (not a re-implementation), token
flow, gateways, timers, DMN, and FEEL expressions behave exactly as they will on
the server.
To run a test and inspect the trace:
- Open a model in a project and click Test.
- Start an instance (optionally supplying initial variables as JSON).
- As each job activates, complete it with mock result variables, fail it, or throw a BPMN error — μ-nano advances the tokens accordingly.
- Fast-forward the virtual clock to fire timers without waiting in real time.
- Watch tokens move on the diagram, and read the execution trace — the ordered list of elements visited, jobs created/completed, and variable snapshots — to confirm the model behaves as intended before deploying anything.
Every node serves a live agent brief at /agent (also /agent.md) — the
"point your agent here" surface (ADR 0051). Hand the URL to any coding agent
(Claude Code, Copilot CLI, or any MCP-driven assistant) and tell it "read
<node>/agent and build me an app". The brief teaches the agent, addressed to
it directly, how to author a Nano App on disk and link it into this node
(import by reference, ADR 0041) — and how Nano works, so it can also just
explain the product to you. It is rendered per node, so it prints this node's
real base URL, on-disk projects root, import endpoint, installed packs and
scaffold templates: an agent needs no other context. A companion /llms.txt
points agent tooling at the brief plus the OpenAPI, AsyncAPI and JSON-Schema
specs. The Studio view exposes it as a Build with an agent button.
The console is extensible: an agentic SDLC pack, language support, app
templates, example apps, event triggers, and themes are shipped as extension
packs — plain npm packages discovered by the nano-ide-ext keyword and
installed from the console UI. The console can install any pack carrying that
keyword; the first-party packs live in the
nano-ide repo, and their manifest schema is
defined here in server/src/console/extensions.rs. See
docs/nano-repositories.md for how the pieces
relate. (This is a Studio-profile authoring tab.)
Authoring a pack. See the Extensions authoring & publishing
guide for the nano-ide.ext.json manifest, the
marketplace, and every pack kind with a minimal example — language, app template,
example, trigger, and theme — plus agentic-SDLC apps and outbound connectors
(both capabilities layered on those kinds), the install/trust model, and how to
publish to npm.
A live process-instance explorer (variables, jobs, incidents) with BPMN XML for each running or completed instance.
Execution traces folded from the engine event stream: a searchable list of process instances (active, completed, terminated) with a per-instance timeline of the elements visited, jobs created/completed, and variable snapshots. Where Explorer shows live instance state, Traces reconstructs the ordered history of how each instance got there.
Author TypeScript job workers in the browser and run them as Node (≥ 22.6) subprocesses over the Falcon protocol, with a live "Running" fleet view (status, throughput, completed/failed, uptime, restarts) and streamed logs. Running workers requires Node installed on the host (see below); the tab still authors code without it. Selected workers can also be exported as a standalone application you run yourself — see Exporting workers as an application.
The worker editor is deliberately styled after the integrated development environments of the early-to-mid 1990s — Borland Delphi, Microsoft Visual Basic — where the wiring was invisible and you focused only on what can't be automatically connected. The BPMN model is the form; the engine binds tasks to job workers by type; the runtime, the Falcon transport, the retry/timeout plumbing, and dependency resolution are all handled for you. You write only the handler body. That ethos drives the code intelligence:
- Full IntelliSense for the worker SDK, offline. The embedded
@nanobpm/workerSDK is registered with the editor's TypeScript language service as a local library, sodefineWorker, thejobobject (job.variables,job.complete,job.fail,job.error,job.jobKey,job.processInstanceKey, …) and every option auto-complete and type-check with no network access whatsoever. - Automatic Type Acquisition (ATA) for any npm package. Import any package
—
import _ from "lodash",import { ... } from "@camunda8/orchestration-cluster-api"— and the editor fetches its type definitions on the fly (from the jsdelivr CDN) and lights up completion, hovers, and signature help for it. This is a progressive enhancement: it needs network access, and degrades silently to SDK-only IntelliSense when offline. The TypeScript compiler is loaded lazily, only the first time you edit a worker file, so the editor stays lightweight. - Cross-file IntelliSense — sibling helpers and a shared library. Reusable
logic doesn't have to live in one file. Split a worker into multiple files and
import { helper } from "./helper.ts"; the editor resolves siblings with full types. For logic shared across every worker, drop a module into the workspace Shared library (the📚 Shared libraryentry below the worker list) and import it from anywhere with the@lib/alias —import { fmtMoney } from "@lib/money.ts". In keeping with the invisible-wiring ethos, there is nothing to configure: the@lib/import map alias is injected into every worker'snode.jsonautomatically, so the same import resolves in the editor (IntelliSense), at runtime (the Node supervisor), and in an exported app (the bundledlib/folder).
The console keeps the user's authoring source of truth in a workspace
directory, deliberately separate from the engine's data dir so that deleting
cluster data leaves your projects and workers intact. The workspace holds the
standalone Workers tab's files at the top level, and every RAD project
under projects/ as a self-contained, runnable Node application:
<workspace>/
├── workers/<name>/ # standalone Workers-tab workers (worker.ts, node.json, …)
├── lib/ # shared library modules for those workers (@lib/…)
├── nano-generated/worker-sdk.ts # embedded Node worker SDK (auto-written)
├── node_modules/ # `npm install` output for the workers' bare deps
└── projects/<project>/ # one directory per RAD project (a runnable Node app)
├── nanobpm.project.json # project config (name, deploy target, platforms)
├── node.json # import map (@nanobpm/worker, @lib/) + start task
├── tsconfig.json # standard-tooling type resolution
├── package.json # npm identity + `npm install` deps
├── main.ts # entrypoint: deploys processes + starts workers
├── resources/
│ ├── processes/<name>.bpmn # BPMN models (deployed to the engine on run)
│ ├── decisions/<name>.dmn # DMN decisions
│ └── forms/<name>.form # forms
├── pages/<name>.page.json # Page Composer pages
├── workers/<name>/ # the project's own workers (worker.ts, node.json, …)
├── lib/ # the project's shared library modules (@lib/…)
├── nano-generated/worker-sdk.ts # embedded Node worker SDK (auto-written)
└── node_modules/ # `npm install` output for the project's bare deps
| Variable | Meaning |
|---|---|
NANOBPMN_WORKSPACE_DIR=<dir> |
Console workspace root (holds the standalone workers/ plus, by default, projects/ — unless NANOBPMN_PROJECTS_DIR moves the projects root). Default ./nanobpm-workspace. Survives deletion of NANOBPMN_DATA_DIR. |
NANOBPMN_PROJECTS_DIR=<dir> |
Override the projects root directly. Default <workspace>/projects. |
NANOBPMN_NODE_BIN=<path> |
Explicit path to the Node (≥ 22.6) binary used to run workers and projects. Tried first, but skipped if it is too old — the next supported candidate (on PATH, then ~/.local/bin/node) wins, so a stale override cannot mask a good Node. |
Each worker is a directory under workers/<name>/ whose worker.ts imports the
embedded SDK and declares a handler:
import { defineWorker } from "@nanobpm/worker";
defineWorker({
type: "my-job",
maxParallelJobs: 10,
async handle(job) {
// job.variables holds the activated job's variables; npm libraries are
// available via `npm:` specifiers.
return { result: 42 }; // resolves -> completeJob({ result: 42 })
// or: job.fail("boom") / job.error("CODE", "msg")
},
});The supervisor runs one node --experimental-strip-types subprocess per enabled
worker that speaks the Falcon protocol directly. Node ≥ 22.6 is required to
run workers (in the console or as an exported app): install it from
https://nodejs.org so the node binary is on PATH, or point
NANOBPMN_NODE_BIN at it. If Node is not installed the Workers tab still authors
and exports code, but starting a worker reports the runtime as unavailable.
Workers don't have to run inside the console. From the Workers tab, click Export app, tick the workers you want (all are selected by default), and Download zip to get a self-contained, runnable application — handy for shipping a worker fleet to another machine or running it outside Nano.
The downloaded nano-workers-app.zip unpacks to:
nano-workers-app/
├── main.ts # entrypoint: deploys models, then starts the workers
├── package.json # npm identity (engines: node >= 22.6) + `npm start`
├── node.json # import map: @nanobpm/worker + every worker's npm deps
├── node-register.mjs # `--import` bootstrap that registers the loader
├── node-loader.mjs # ESM loader that makes Node honour node.json's imports
├── README.md # how to configure and run it
├── sdk/worker-sdk.ts # the embedded worker SDK (same source as the console)
├── workers/<name>/… # each selected worker's source
└── resources/ # drop your .bpmn models here (auto-deployed on startup)
On startup the app deploys every .bpmn file in resources/ to the target
engine, then runs the bundled workers. Add your own models by dropping their
.bpmn files into resources/ before starting. Run it with Node:
cd nano-workers-app
# Point at your Nano gateway (defaults to http://127.0.0.1:8080):
export NANOBPMN_BASE_URL=http://127.0.0.1:8080
npm install
npm start # node --experimental-strip-types --import ./node-register.mjs main.tsThe bundled README.md documents the same steps.
Dependencies travel with the app. The exporter scans the selected workers'
source for import/import(...) specifiers, resolves each to its npm package
(handling scoped names, subpath imports, and bare specifiers), and writes a
dependency manifest into the app's package.json/node.json import map. So a worker that
imports lodash or @camunda8/orchestration-cluster-api produces an export
whose npm: resolution works out of the box — no manual dependency wrangling.
Single-node defaults are tuned for correctness and need no thought: one partition,
sync durability, backpressure on. The knobs below only matter once you cluster
(NANOBPMN_RAFT=on, NANOBPMN_NODES=…) or push a node toward saturation. They are
deliberately orthogonal — pick each axis independently for your workload.
- Partitions ≥ nodes, always. Each partition is owned by exactly one node
(
owner = partition % num_nodes), and a clustered node that owns zero partitions aborts on startup. The clean, balanced choice is one partition led per node. Use a higher multiple only if you want finer rebalancing granularity —NANOBPMN_PARTITIONScannot be changed in place (the journal layout differs). NANOBPMN_RF=3for fault tolerance. RF is the number of copies per partition;RF=1(default) is no replication.RF=3survives one node loss. KeepRF≤ node count; use an odd voter count per group.- Throughput scales with partitions, not nodes alone. Each partition is a single writer (one fsync + apply loop), so aggregate write throughput tracks the number of led partitions.
| If you want… | Tune | To |
|---|---|---|
| Lowest write latency (small/medium concurrency) | NANOBPMN_DURABILITY=async + NANOBPMN_REPLICATION=leader-durable |
Ack on the leader's local durable append+apply — no fsync-before-ack wait and no follower round-trip. Cost: a just-acked tail can be lost on an ungraceful leader loss (bounded, never divergent). |
| Zero-data-loss durability (the default) | leave NANOBPMN_DURABILITY=sync, NANOBPMN_REPLICATION=quorum |
200/204 means fsync'd locally and majority-committed. Strongest guarantee; highest per-write latency. |
| High throughput under worker over-provisioning | Handled automatically by the default NANOBPMN_REPLICATE_ACTIVATION=auto; force with =0 (leader-local) or =digest |
auto already keeps activation off the Raft log for large-payload partitions (avoiding the ~125× collapse) while keeping strict leases for small ones. Force =0/=digest to keep the lease off the log unconditionally (~3× activation throughput). All modes stay at-least-once. |
| Even job drain across nodes | on by default (NANOBPMN_ACTIVATION_FAIRNESS=2); set =off to disable |
Default 2 caps each source by its live backlog so the deepest node drains fastest; 1 is rotation+quota only; off restores strict local-first. |
| A producer that outpaces the workers | leave backpressure on (default Adaptive), or pin NANOBPMN_BACKPRESSURE_MAX_INFLIGHT=<n> |
Adaptive (AIMD) sizes the in-flight watermark from measured latency and sheds excess creates with 503 RESOURCE_EXHAUSTED, so the producer converges to the drain rate. |
| Behaviour at the saturation ceiling | NANOBPMN_SLA_MODE=latency (default) or =admission |
latency holds a tight tail and a bounded backlog via the active-backlog governor, sacrificing peak throughput (time-to-complete SLA). admission drops the governor to run at the true drain ceiling (~+48% throughput in test), accepting a much longer tail and a backlog that grows to the memory-safety rails (start-every-process SLA). The AIMD engine-overload guard and the memory-safety rails stay armed in both modes; arming AIMD in admission tightens its tail (~40% lower p90) at no throughput cost, but does not by itself bound the backlog. |
| Bounded memory after bursts | NANOBPMN_IDLE_PURGE_MS, NANOBPMN_HISTORY_MAX_INSTANCES, NANOBPMN_VAR_SPILL* |
Idle-purge compacts hot state and returns freed arenas to the OS. Cap retained completed instances to bound read-model growth. |
| Bounded var-store WAL on disk | NANOBPMN_VARSTORE_WAL_CHECKPOINT_SECS (default 30, 0/off disables) |
Periodically runs wal_checkpoint(TRUNCATE) on the durable var-store so its -wal file can't grow without bound under a sustained large-payload write load. |
- Strong durability (default, money-movement workloads):
RF=3,NANOBPMN_RAFT=on, leave durability/replication/activation unset. Every ack is fsync'd and quorum-committed; activation defaults toauto(strict replicated leases for small payloads, digest for large so throughput never collapses). Pair with a persistentNANOBPMN_DATA_DIRper node. - Low latency (interactive workflows, modest concurrency):
RF=3,NANOBPMN_DURABILITY=async,NANOBPMN_REPLICATION=leader-durable,NANOBPMN_REPLICATE_ACTIVATION=digest. Bounded-loss, self-healing failover. - Max throughput / benchmarking: one partition led per node,
NANOBPMN_REPLICATE_ACTIVATION=0. Job-drain fairness (=2) and create placement (balanced) are already on by default; setNANOBPMN_CREATE_PLACEMENT=offonly for a strict apples-to-apples blind-round-robin baseline. Always benchmark the release binary.
Durability/replication tiers are chosen at startup from the on-disk log; switching tiers on an existing data directory is unsupported. Start each reconfigured cluster from a fresh
NANOBPMN_DATA_DIR.
Nano BPM can record every instance's inputs so runs can be replayed and analysed later (the basis for the runtime process-optimization research direction below). With the c8ctl plugin:
c8ctl nano start 3 --capture
c8ctl nano status # shows "trace capture: on"--capture sets NANOBPMN_TRACE_STIMULI=1 on every node, enabling the Tier 2
recorded-input (stimuli) log and auto-enabling Tier 1 variable capture. Read a
trace back from any node:
GET /console/api/traces/{instanceKey}
→ { creationVariables, stimuli[], <per-incident variables> }
| Env var | Default | Purpose |
|---|---|---|
NANOBPMN_TRACE_STIMULI=1 |
off | Enable Tier 2 recorded-input replay (also enables Tier 1). |
NANOBPMN_TRACE_VARIABLES_MAX_BYTES |
16384 | Max captured variable payload bytes. |
NANOBPMN_TRACE_STIMULI_MAX |
1024 | Max recorded stimuli per instance. |
NANOBPMN_TRACE_CAPACITY |
2000 | Max traced instances retained in memory. |
NANOBPMN_TRACE_FILE |
(unset) | Append each finished trace to this file as one NDJSON line, then drop it from memory. Durable across restart and bounds memory to the active set, so recorded-input capture can stay on in production. Unset keeps the in-memory-only behaviour. Each line is the same JSON as GET /console/api/traces/{key}. |
NANOBPMN_TRACE_FILE_MAX_BYTES |
(unset) | Rotate the trace file at this size (<path>.1 … <path>.<keep>). Unset leaves rotation to logrotate — which must use copytruncate, because the writer opens the file once and never reopens it (rename/create rotation would leave it writing to the renamed file). |
NANOBPMN_TRACE_FILE_KEEP |
5 | Rotated trace files to keep. |
NANOBPMN_TRACE_FILE_TAIL |
0 | Recently-finished traces to keep in memory for the console after writing them to the file. |
NANOBPMN_TRACE_FILE_QUEUE_BYTES |
16777216 | Byte budget of the writer channel between the engine and the sink thread. A full budget drops finished traces (the dropped counter returned by sink_stats()) rather than back-pressuring the engine — raise it if drops appear under load. Bounded in bytes (not trace count) so a stalled disk can never retain more than this much serialized trace. The budget bounds backlog, not one trace's size: a single trace larger than the whole budget is still admitted to an empty queue (a healthy writer drains it immediately), so it is a backlog ceiling, not an absolute memory ceiling. |
NANOBPMN_TRACE_FILE_FLUSH_MS |
1000 | How often the writer thread flushes its buffer to the file, on an absolute deadline that does not move when new traces arrive. A crash can lose at most one flush interval of traces. |
Roadmap: beyond the Camunda-compatible engine, see
docs/process-optimization-design.mdfor the closed-loop runtime process optimization direction — execution trace export, deterministic recorded-input replay, WASM-powered simulation, a cost/SLA model, canary experiments, and an LLM-in-the-loop reasoning plane built on the engine's event-sourced core.
An in-engine process debugger (proof-of-concept) lets you set a breakpoint on
a flow node, pause a process instance inside the engine mid-command, inspect its
variables, and step or resume — with the paused element highlighted on the diagram
in VS Code. It is a four-piece stack: the engine-core stepping executor →
engine-wasm debug surface → the standalone dap-adapter (Debug
Adapter Protocol) → the vscode-bpmn-debug extension.
For a copy-pasteable runbook — a 30-second headless proof (no VS Code) and the
full F5 click-the-diagram walkthrough — see
docs/debugger-getting-started.md.
End users do not need to build anything — the c8ctl plugin ships a prebuilt binary. To build from source, run the engine tests, or work on the code-generation pipeline, see DEVELOPMENT.md.