Skip to content

Messaging

The messaging subsystem gives your component one interface over two transports: Greengrass IPC (--transport IPC) on a Nucleus-managed device, or a dual-MQTT provider (--transport MQTT) that connects to a local broker and, optionally, AWS IoT Core. The same publish/subscribe, request/reply, and message envelope work identically across Java, Python, Rust, and TypeScript, so components written in any language interoperate on the same topics.

Topics live in the Unified Namespace: mint them with the gg.uns() builder rather than concatenating strings, and every envelope built through a config-bound builder carries a self-identifying identity element.

Connections and subscriptions are synchronousconnect, publish, and subscribe run inline rather than returning a pending promise. Be aware, though, that the standalone (dual-MQTT) provider swallows the underlying Paho MqttException (it logs the error and returns), so a normal return does not by itself guarantee the broker accepted the publish/subscribe — check the logs (and connected()) when diagnosing a missing message.

Every component reaches messaging through its EdgeCommons handle. The accessor name and return shape differ slightly per language.

getMessaging() returns a MessagingClient instance — its methods are instance methods.

MessagingClient messaging = gg.getMessaging();

Every message EdgeCommons sends on the wire is a JSON object with four parts. The shape and key names are identical across all four languages so they interoperate.

{
"header": {
"name": "TemperatureReading",
"version": "1.0",
"timestamp": "2026-06-29T12:00:00Z",
"uuid": "5f6c...",
"correlation_id": "a1b2...",
"reply_to": "edgecommons/reply-..."
},
"identity": {
"hier": [
{ "level": "site", "value": "dallas" },
{ "level": "device", "value": "gw-01" }
],
"path": "dallas/gw-01",
"component": "my-component",
"instance": "main"
},
"tags": { "site": "lab" },
"body": { "celsius": 22.4 }
}
  • header — message metadata. Keys are snake_case on the wire (correlation_id, reply_to, uuid). reply_to is omitted when there is no reply topic; correlation_id/uuid/timestamp are filled in automatically if you do not set them.
  • identity — the publisher’s UNS identity: the ordered enterprise hierarchy (hier, last entry = the device), the precomputed path, and the component/instance addressing tokens. Stamped automatically by any config-bound builder; a message built without config (a bootstrap request, raw bridging) legally omits it. Consumers identify the sender from this element — never by parsing the topic.
  • tagsbusiness context only (app, org, cost center) — your custom tags config keys. The device identity lives in the identity element, not in tags. tags is emitted only when tags were stamped.
  • body — your payload. Any JSON value.

For small binary payloads, the body uses an explicit marker object rather than a bare base64 string:

{
"_edgecommonsBinary": {
"encoding": "base64",
"length": 5,
"data": "AAEC/v8="
}
}

This is intentionally bounded control-payload support, not a frame/video streaming transport: decoded binary bodies are limited to 64 KiB. Java byte[], Python bytes/bytearray, and TypeScript Buffer/Uint8Array payloads serialize to this marker. Rust uses MessageBuilder::binary_payload(...). Consumers keep using getBody() / get_body() / getBody() / body for the raw JSON marker, or use the typed binary accessors getBinaryBody() / get_binary_body() / binary_body() / getBinaryBody() to decode and validate it.

A raw message has no envelope — it serializes as { "raw": <value> } instead. When a component receives a payload that is not an envelope (none of header/identity/tags/body), or non-JSON bytes, it is delivered to your handler as a raw message (non-JSON bytes become a raw string). See Raw messages below.

Construct messages with MessageBuilder — not the raw constructors. The builder stamps the uuid, correlation_id, and timestamp for you, and — when you supply the config — the custom tags and the UNS identity element (with the optional per-message instance token — omit it for a component-scoped message).

Message msg = MessageBuilder.create("TemperatureReading", "1.0")
.withPayload(payload) // JsonObject, Map, POJO, or List
.withConfig(configManager) // stamps tags + the UNS identity
.withInstance("kep1") // optional per-message instance token (omit for component scope)
.build();

For tests, vectors, and relays, an explicit withIdentity(...) / with_identity(...) / .identity(...) override always wins over the config-resolved identity. All four builders also accept explicit uuid and timestamp setters for deterministic envelopes.

messaging() takes literal topics — there is no hidden topic construction. For a topic in the Unified Namespace, mint it with gg.uns() (or an instance handle’s uns()), which validates the token charset and the ≤ 7-slash IoT Core depth budget at build time:

String topic = gg.getUns().topic(UnsClass.APP, "order/received");
gg.getMessaging().publish(topic, msg); // ecv1/{device}/{component}/app/order/received

Arbitrary non-UNS topics remain fully supported (bridging an external MQTT system, the cloudwatch/metric/put contract). The single restriction is the reserved-class guard: a raw publish (or request/reply) to a reserved-class UNS topic — ecv1/.../{state|metric|cfg|log} — is rejected (ReservedTopicException / ReservedTopicError / EdgeCommonsError::ReservedTopic), because those classes are library-owned: the heartbeat publishes state, the metrics subsystem publishes metric, and the config subsystem publishes cfg. Subscribing to reserved classes is always allowed. See the UNS API reference.

publish sends an envelope to a topic. subscribe registers a handler that receives the topic and the decoded Message for each match. Topic filters use the usual MQTT wildcards (+ single level, # multi level). The examples below use a literal topic; UNS-native code mints the topic via uns() as shown above.

// subscribe — 3rd arg is maxConcurrency. The callback is a BiConsumer<String,Message>.
messaging.subscribe("sensors/temp", (topic, m) -> {
System.out.println(m.getHeader().getName() + " -> " + m.getBody());
}, 1);
// publish
messaging.publish("sensors/temp", msg);

Use the *Raw publish methods to send an unwrapped JSON value (for example to interoperate with a non-EdgeCommons producer). On the receiving side, you do not call a separate “subscribe raw” — your normal subscribe handler receives a raw Message whenever the incoming payload is not an envelope, and you detect it with the raw accessors.

// publish a raw JSON object (no envelope)
JsonObject metric = new JsonObject();
metric.addProperty("value", 42);
messaging.publishRaw("metrics/raw", metric);
// handle a possibly-raw message
messaging.subscribe("metrics/raw", (topic, m) -> {
if (m.getRaw() != null) {
System.out.println("raw: " + m.getRaw());
} else {
System.out.println("envelope body: " + m.getBody());
}
}, 1);

request publishes a message to a service topic, subscribes to a private reply topic, and gives you a future that completes when the reply arrives. On the responder side, reply(request, reply) copies the request’s correlation_id onto the reply and publishes it to the request’s reply_to topic — so the requester’s future resolves to the right answer.

Every request carries a framework-owned deadline. The default is messaging.requestTimeoutSeconds (30 s; 0 disables); an explicit per-call timeout always wins. When the deadline fires, the library unsubscribes the ephemeral reply topic and completes the future exceptionally — even if you never await it — so an abandoned request does not leak its reply subscription (and trip the Greengrass shared-connection quota). Consequences: a no-arg get()/await is bounded by the deadline, and a get(t) waits at most min(t, deadline).

The future type differs per language: Java extends CompletableFuture<Message>, Python returns an Iou, Rust returns a Result<ReplyFuture> you await, and TypeScript returns a ReplyFuture (PromiseLike) you await.

ReplyFuture extends CompletableFuture<Message>, so use the standard future API. On deadline it completes exceptionally with a java.util.concurrent.TimeoutException. The per-call override is request(topic, msg, Duration) (null = the default, Duration.ZERO = disabled).

ReplyFuture future = messaging.request("svc/op", request); // 30 s default deadline
Message reply = future.get(); // safe: bounded by the deadline
ReplyFuture slow = messaging.request("svc/op", request, Duration.ofSeconds(120));
// responder side:
messaging.reply(request, replyMsg); // copies correlation id automatically

To abandon an in-flight request before it completes, call cancelRequest / cancel_request (Rust/TS also expose .cancel() on the future), which tears down the reply subscription. Reply-arrival, the deadline, and cancellation race through one idempotent settle path — exactly one wins; a straggler reply after settle is logged at DEBUG and dropped.

Each subscription has two independent bounds:

  • maxConcurrency — the maximum number of handler invocations that run at once for that subscription. A value <= 0 means uncapped (Java/Python skip the semaphore; Rust/TS clamp the value up to 1). Use a low cap to serialize a handler that touches shared state; raise it to process bursts in parallel.
  • maxMessages — the bounded in-memory queue depth per subscription. When the queue is full, new messages are dropped with a warning (the subscription is never blocked). The default is 10000 in Java/Python (DEFAULT_MAX_MESSAGES), 32 in TypeScript, and is caller-supplied in Rust.

Remember the argument-order divergence (see the warning above): Java/Python take maxConcurrency first, Rust/TypeScript take maxMessages first.

With the dual-MQTT transport, the plain methods (publish, subscribe, request, reply) target the local broker. Northbound methods target the configured northbound transport and take a QoS:

Local Northbound
publish / publishRaw publishNorthbound / publishNorthboundRaw
subscribe subscribeNorthbound
request requestNorthbound
reply replyNorthbound
unsubscribe unsubscribeNorthbound

QoS defaults are config-backed for operations that do not carry an explicit QoS parameter. The standalone local and northbound MQTT brokers support QoS 0 (at most once), 1 (at least once), and 2 (exactly once). Greengrass IoT Core IPC APIs support only QoS 0 and 1 through their explicit SDK enums. Direct northbound methods that take a QoS argument still use that per-call value.

All four languages expose an EdgeCommons-owned Qos enum for messaging APIs. The config file uses numeric MQTT QoS values:

{
"messaging": {
"local": {
"host": "localhost",
"port": 1883,
"clientId": "my-component-local",
"qos": { "publish": 1, "subscribe": 1 }
},
"northbound": {
"host": "northbound-broker.example.com",
"port": 8883,
"clientId": "my-component-northbound",
"qos": { "publish": 2, "subscribe": 1 }
}
}
}

messaging.local.qos.publish drives local publish, publishRaw, request publish, and reply publish. messaging.local.qos.subscribe drives local subscribe and request reply subscriptions. The matching messaging.northbound.qos defaults drive northbound MQTT request/reply operations that do not expose an explicit QoS argument.

import com.mbreissi.edgecommons.messaging.Qos;
messaging.publishNorthbound("cloud/telemetry", msg, Qos.AT_LEAST_ONCE);
messaging.subscribeNorthbound("cloud/cmd", (topic, m) -> handle(m), Qos.AT_LEAST_ONCE, 1);

On the HOST platform (and for local testing) the MQTT transport reads a JSON messaging section that describes the brokers. It is loaded from the positional argument to --transport MQTT <messaging_config.json> (on KUBERNETES, from the mounted ConfigMap default) — not from the active config source (-c); an embedded messaging section in a -c source is not read by the MQTT transport.

This separate broker file is parsed leniently (plain Gson / the equivalent JSON decoder in each language) and is not validated against the config JSON Schema at runtime — unknown or mistyped keys are silently ignored rather than rejected, so double-check the field names below.

{
"messaging": {
"local": {
"host": "localhost",
"port": 8883,
"clientId": "my-component",
"qos": { "publish": 1, "subscribe": 1 },
"credentials": {
"caPath": "/certs/ca.crt",
"certPath": "/certs/client.crt",
"keyPath": "/certs/client.key"
}
},
"northbound": {
"endpoint": "xxxx-ats.iot.us-east-1.amazonaws.com",
"port": 8883,
"clientId": "my-component",
"qos": { "publish": 2, "subscribe": 1 },
"credentials": {
"caPath": "/certs/AmazonRootCA1.pem",
"certPath": "/certs/device.pem.crt",
"keyPath": "/certs/private.pem.key"
}
},
"requestTimeoutSeconds": 30
}
}

Key rules (all verified, identical across the four languages):

  • northbound is optional. Omit it for a local-only (“single-broker”) deployment — the northbound methods are simply unused.
  • Local-broker TLS is keyed solely on caPath presence. TLS is enabled when (and only when) the local credentials.caPath is set (useSSL = credentials != null && caPath != null); if caPath is absent, the connection is plaintext.
    • caPath only ⇒ server-only TLS.
    • caPath + certPath + keyPathmutual TLS.
    • A client certPath/keyPath without a caPath stays plaintext (the cert is ignored).
    • username/password apply only to a plaintext (no-caPath) local broker. They are set in the else branch under if (!useSSL), so they are mutually exclusive with TLS: once caPath is present the connection goes TLS-only and any username/password is ignored.
  • Northbound is a generic MQTT broker. It is plaintext when caPath is absent, server-TLS when caPath is present, and mutual TLS when certPath + keyPath are also present.
  • host is an opaque string — a Kubernetes Service DNS name works unchanged.
  • local.type is optional and ignored — it is parsed into the config record but never read; there is no defaulting to "mqtt".
  • requestTimeoutSeconds (number, default 30, 0 = off) sets the default request deadline.
  • local.qos / northbound.qos set MQTT QoS defaults for operations on that broker without an explicit QoS argument. publish and subscribe accept 0 (at most once), 1 (at least once), or 2 (exactly once).

Rust and TypeScript expose a substitutable seam so you can inject a fake transport in tests without a broker. Java and Python do not — they have concrete clients only, so you test against the real MessagingClient (point it at a local EMQX broker, or mock at the provider boundary).

No service interface or DI. Test against the concrete MessagingClient — typically against a local broker. Because MessagingClient uses process-global state, reset/close it between tests so state does not leak.

// no substitutable seam — exercise the real client against a local broker
MessagingClient messaging = gg.getMessaging();