JMS
How to consume messages from a message broker and turn each one into a flow execution: what the connector is, how to configure and start it, what guarantee it gives you, and what it does when things go wrong.
What it is
A separate process. It subscribes to a queue on your broker, and for every message it receives it makes one call to the flow’s queued endpoint:
broker queue ──STOMP──▶ connector ──POST /enqueue──▶ platform queue ──▶ flowThe server does not know the broker exists. From its side these are ordinary submissions to
POST /flows/{tenant}/{flow}/enqueue, so execution, retries, backoff and the dead-letter queue
are the platform’s normal queued behaviour — see Queues, retries and the
DLQ. The connector’s job stops at getting the bytes accepted.
It talks STOMP 1.2 over plain TCP. Any broker that exposes a STOMP endpoint works; “JMS” is the shape of the integration, not the wire protocol.
Configuration
Configuration lives in the front matter of the same .flow.md file you deploy. The platform
ignores front-matter keys it does not recognise, and the connector reads only the keys below —
one file describes both halves.
| Key | Type | Default | Meaning |
|---|---|---|---|
trigger |
string | required | Must be jms. A file without it is skipped in silence. |
trigger_queue |
string | required | Broker queue name. Subscribed as /queue/<name>. |
trigger_connection |
string | required | stomp://host:port. ${VAR} is substituted from the environment. |
trigger_concurrency |
integer | 1 |
Number of workers for this flow. Each opens its own connection and its own subscription. Values below 1 become 1. |
max_message_bytes |
integer | 1048576 |
Largest message body the connector will forward. Minimum 1. trigger_max_body_bytes is a deprecated alias. |
flow: and tenant: are read too — they are what the submission URL is built from.
A value that is not a number is ignored and the default stands. Quotes around a value are
stripped. Lines starting with # are skipped.
Environment substitution
${VAR} inside trigger_connection is replaced with the value of that environment variable
when the connector starts. Three cases stop it before it consumes anything, each naming the
variable: the variable is not set, the ${ has no closing }, or the value itself contains
${. A half-built broker address would otherwise produce a connector that runs and quietly
consumes nothing.
The connector reads the environment it was given. Unlike nexus, it does not load a .env
file.
A configured flow
---flowmarkdown_version: "0.1"flow: order-intaketenant: acmetrigger: jmstrigger_queue: ORDERS_INtrigger_connection: ${ORDERS_BROKER}trigger_concurrency: 2max_message_bytes: 1048576queue_max_attempts: 5queue_initial_delay_ms: 1000effects: [http_egress]---
## Step: normalise```ntd{ "orderId": "{{ $.orderId }}", "total": {{ $.total }}, "receivedAt": "{{ date_format(now()) }}"}```
## Step: forwardeffects: [http_egress]endpoint: https://orders.example.com/ingestmethod: POSTcontent_type: application/jsonStarting it
The binary is nexus-connector, delivered built alongside the platform. Give it one or more
flow files:
$ nexus-connector --server http://localhost:9090 \ --store /var/lib/nexus/jms.db \ order-intake.flow.md invoice-intake.flow.md| Flag | Default | Meaning |
|---|---|---|
--server <url> |
http://localhost:9090 |
Base URL of the server. Also read from NEXUS_SERVER_URL. |
--store <path> |
nexus-jms-connector.db |
Local database used for crash recovery. Created if absent. |
--api-key <key> |
none | Sent as Authorization: Bearer <key> on every request the connector makes. Also read from NEXUS_API_KEY. |
<flows...> |
required | One or more .flow.md paths. At least one must carry a JMS trigger. |
At startup, in this order: anything left in the local store from a previous run is submitted
first; then each file is read. A file with no trigger: jms is skipped. A file that cannot be
read or parsed is fatal — the connector exits naming the file, rather than starting with one
queue silently unconsumed. If no file carries a JMS trigger, it exits.
Then it runs until Ctrl+C. Operational output goes to stderr and RUST_LOG sets its level.
From broker message to submission
| What the broker sends | What the connector does with it |
|---|---|
| The message body | Becomes the request body of POST /flows/{tenant}/{flow}/enqueue, byte for byte. |
The message-id header |
Becomes the message’s identity for deduplication, and is recorded in the log. |
The ack header |
Used to acknowledge that one message. |
| Everything else | Not forwarded. Broker headers and JMS properties do not reach the flow. |
The submission is always sent as application/json, so the message body must be JSON. The
server parses it, stores the parsed value, and the flow reads it as $. A body that is not JSON
is answered with 400 — and 400 is still a status the connector retries, so that message is
retried forever and the worker never moves on. Convert on the producing side, or publish JSON.
(401, 403 and 413 are the statuses the connector treats as final; see Permanent refusals
below.)
The connector and the server read the same max_message_bytes out of the same front matter, so
the two ceilings agree by construction — raise it in the file and both halves move together. What
they do not share is the installation-wide default underneath it: a flow that declares nothing gets
the server’s max_message_bytes from system_config, and if an operator has lowered that below
1 MiB, the connector will forward messages the server then refuses with 413. See
Limits.
Ordering: one worker consumes and submits one message at a time, in the order the broker
delivers them. With trigger_concurrency above 1 there are that many independent consumers and
nothing preserves order between them.
Delivery guarantee
At-least-once. A message that reaches the connector is submitted at least once; it can be submitted twice. Make the flow tolerate a repeated message.
The mechanism, per message, in this order:
- Write the message to the local store.
- Report
connector_received. ACKthe broker — the message leaves the queue.POSTit to/enqueue, retrying every failure until a 2xx: 1 second, doubling to a 60-second ceiling, forever.- Mark it delivered in the local store.
The store is what makes step 3 safe: once the bytes are on local disk the broker’s copy is no longer needed, and the submission can take as long as it takes. Steps 4 and 5 do not depend on the broker connection — a drop mid-retry changes nothing.
At the next start, everything still marked in flight is submitted again and then marked
delivered, before any worker connects. A broker that redelivers a message-id already marked
delivered gets an ACK, and the message is dropped rather than submitted twice.
Two things produce a duplicate:
- A crash between the server accepting a submission and step 5. The message is still marked in flight, so it is submitted again at the next start.
- A broker that does not set
message-id. The connector assigns a fresh identity to each delivery, so nothing matches on redelivery and the dedupe check cannot fire. Most brokers set it.
Records of delivered messages older than 24 hours are discarded when the connector starts. Deleting the store file discards them all, and discards anything still in flight with them.
Oversized messages
A body larger than max_message_bytes is discarded as it is read. The message is ACKed
— it is gone from the broker — and never forwarded. A connector_oversized event is reported.
This is a drop, not a dead-letter: after the ACK there is no copy anywhere. Set the limit
deliberately, and if losing a message is unacceptable, make the producer refuse to publish one
that large.
The size field on that event is reported as zero, because the body is thrown away as it is read rather than buffered.
Note the asymmetry: a message stopped here, by the connector’s own limit, is dropped and no copy
survives. One the platform refuses with 413 is kept — see below.
Permanent refusals
The connector retries a rejected submission — but only where retrying can work. Network failures,
429 and 5xx are retried; 401, 403 and 413 are final answers and end the attempt.
| Status | What it means | What happens to the message |
|---|---|---|
401, 403 |
The key is wrong, revoked, expired, or not allowed on this flow or tenant | The message stays in the connector’s pending store and the worker stops. Fix the key, restart, and replay_pending submits it. |
413 |
The message is over the platform’s ceiling for this flow | Quarantined in the local store, body included, and the worker carries on |
429 |
Rate limited | Waits for Retry-After when the server sends one (seconds form, capped at 5 minutes), otherwise backs off, then retries |
5xx, network |
The platform is down or restarting | Backs off and retries |
The distinction is what is wrong. On 401/403 nothing is wrong with the message, so it is kept
untouched; a worker that carried on would burn the rest of the queue against the same refusal. On
413 the message is the problem, so it is set aside and work continues. That 401 really does mean
“wrong credential” is guaranteed by the platform: the gate answers 503, never 401, when it cannot
decide.
Every permanent refusal is logged at error with the status, tenant, flow and what to do about it.
Quarantined messages are not evicted by the 24-hour cleanup — they hold the only surviving copy of a
message that has already been acknowledged to the broker, so inspect the store after one of these.
Not covered: 400, 404 and 422 are still retried indefinitely. 404 is why — a connector
started before its flow is deployed gets one legitimately, and there retrying is the right answer.
When the broker connection drops
The session ends and the worker reconnects. It waits 2 seconds, then 4, 8, 16, 32, and 60 thereafter. A session that stayed up at least 30 seconds before failing resets the wait to 2 seconds, so an occasional drop does not push a healthy connector into minute-long gaps. It never stops trying.
A broker that is unreachable at startup behaves the same way — the worker retries until it
answers. So do a wrong port and an address that does not begin with stomp://.
Events it reports
The connector calls POST /connector/events/{tenant}/{flow} so that a message picked up from a
broker appears in the same structured log as an HTTP request. The call is fire-and-forget: a
failure is logged and message processing continues.
| Event | When | Carries |
|---|---|---|
connector_received |
The message is on local disk, not yet submitted | Correlation ID, message ID, worker number |
connector_oversized |
A message exceeded the size limit and was dropped | Message ID, worker number |
The connector generates one correlation ID per message, reports it on connector_received, and
sends it as X-Correlation-ID on the submission. The connector_received and queue_enqueued
entries therefore carry the same ID, and so does everything the flow logs when the worker picks
the message up. See Logging and audit.
A complete setup
Register the tenant, deploy the flow, and issue the key the connector will present:
$ nexus tenant create --id acme --display-name "Acme Ltd"$ nexus deploy order-intake.flow.md --version 1.0.0$ nexus keys create --tenant acme --label jms-connector \ --scopes '*' --flows order-intakecreated API key id=1 key: nxk_4f19c0b27ad3489e93f5c1e6a70d2b84
$ nexus serve --port 9090Give the connector its own key, scoped to the one flow it submits to with --flows. It authenticates
like any other caller — there is no connector-shaped exemption — so a shared key would mean revoking
it takes the other callers down too.
Point the connector at both the broker and the server:
$ export ORDERS_BROKER=stomp://broker.example.com:61613$ export NEXUS_API_KEY=nxk_4f19c0b27ad3489e93f5c1e6a70d2b84$ nexus-connector --server http://localhost:9090 \ --store /var/lib/nexus/jms.db \ order-intake.flow.mdWithout a key the connector gets 401 on its first submission and stops, leaving the message
recoverable on the broker: a credential refusal is terminal, not something to retry forever.
Publish a JSON message to ORDERS_IN, and it arrives as the flow’s input:
{ "orderId": "A-1", "total": 42 }The flow runs on the platform’s queue worker, so its result is not returned anywhere — whatever the integration is for has to happen in an effect. Follow the message by its correlation ID in the log, or watch the Queue page of the management UI.
To change the flow’s steps, deploy a new version; the queue worker resolves the newest artifact
when it picks a message up, and the connector does not need restarting. To change any
trigger_* key, restart the connector — it reads the file once, at startup.
Not supported
Broker credentials and TLS: the address is a host and a port, nothing else. Topics — the
subscription is always a queue. Broker headers and message properties are not passed to the
flow. The connector does not sign its submissions, so a server started with --secret rejects
them.