Skip to content

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 ──▶ flow

The 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-intake
tenant: acme
trigger: jms
trigger_queue: ORDERS_IN
trigger_connection: ${ORDERS_BROKER}
trigger_concurrency: 2
max_message_bytes: 1048576
queue_max_attempts: 5
queue_initial_delay_ms: 1000
effects: [http_egress]
---
## Step: normalise
```ntd
{
"orderId": "{{ $.orderId }}",
"total": {{ $.total }},
"receivedAt": "{{ date_format(now()) }}"
}
```
## Step: forward
effects: [http_egress]
endpoint: https://orders.example.com/ingest
method: POST
content_type: application/json

Starting it

The binary is nexus-connector, delivered built alongside the platform. Give it one or more flow files:

Terminal window
$ 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:

  1. Write the message to the local store.
  2. Report connector_received.
  3. ACK the broker — the message leaves the queue.
  4. POST it to /enqueue, retrying every failure until a 2xx: 1 second, doubling to a 60-second ceiling, forever.
  5. 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:

Terminal window
$ 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-intake
created API key id=1
key: nxk_4f19c0b27ad3489e93f5c1e6a70d2b84
$ nexus serve --port 9090

Give 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:

Terminal window
$ 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.md

Without 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.