Skip to content

Explanation — How the UNS bridge works, and why

This page is the mental model. For exact options see reference/; for tasks, the how-to guides; for worked configs, sample-configurations.md.

The Unified Namespace (UNS) gives every message a globally-meaningful topic — ecv1/{device}/{component}/{instance}/{class}[/channel]. But physically each device has its own bus: a local MQTT broker on a HOST/Docker device, the Nucleus IPC bus on a Greengrass core. Those buses do not see each other. A site-wide consumer — a historian recording every device, an MES integration, the edge console — would otherwise have to open a connection to every device’s broker and know each one’s address.

The uns-bridge makes the logical UNS a real, single bus. Deploy one bridge per device bus; each subscribes its device’s UNS traffic and republishes it, topic-verbatim, onto a shared site broker under the device’s own namespace. Now the historian connects to one bus and subscribes six wildcards to see the whole plant. Commands flow the other way: the bridge pulls down commands the site addresses to its device.

The design north star is edge-first with an intermittent uplink. The bridge must come up and keep serving the device bus even while the WAN to the site is down; the site connection retries in the background and reconnects transparently. Data lost during an outage is lost by design — durability is the streaming subsystem’s job, not the bus’s — with one deliberate exception: events/alarms (evt).

A bridge is one process with two connections: the device bus and the site broker. The device-bus connection is shared — the EdgeCommons runtime owns it for the bridge’s own observability, and the relay borrows the very same connection through the runtime’s raw provider.

flowchart LR
  subgraph device["Device bus (local MQTT on HOST / Nucleus IPC on GREENGRASS)"]
    conn["ONE device-bus connection\n(runtime's provider, shared)"]
  end
  subgraph site["Site UNS broker (MQTT)"]
    sitec["SITE conn\n(MqttProvider + derived LWT)"]
  end
  runtime["edgecommons runtime:\nidentity · heartbeat state ·\ncfg announce · gg.metrics()"] --> conn
  engine["Relay engine + policy +\nreply proxy"] -. "gg.raw_device_provider()" .-> conn
  engine --> sitec
  conn -. "uplink 6 classes\n(incl. the bridge's OWN state/cfg/metric)" .-> sitec
  sitec -. "downlink cmd\n(own device)" .-> conn
Connection Owner Built from Purpose
DEVICE BUS (shared) the EdgeCommons runtime; the relay borrows it the runtime’s resolved transport — MQTT on HOST (--transport MQTT), Nucleus IPC on GREENGRASS (--transport IPC); the relay obtains the same raw provider via gg.raw_device_provider() the bridge’s own identity, logging init, the heartbeat state keepalive, the effective-(redacted-)config cfg announce, gg.metrics(), and the library-owned SIGTERM/Ctrl-C shutdown — and the provider-level protobuf relay, the reply proxy’s device-side reply topics, and the reconnect rehydration broadcast
SITE the bridge the bridge’s own component.instances[] "site" entry, by reusing the edgecommons core’s public MQTT provider with a bridge-derived Last-Will (always MQTT) the uplink target and downlink source; carries the private Last-Will UNREACHABLE contract

Why the relay shares the runtime’s device-bus connection. The relay must operate at the raw provider level — below the reserved-class publish guard — because it forwards messages other components authored (including publishes to reserved classes like state/metric). The runtime exposes its own raw MessagingProvider through gg.raw_device_provider() precisely for guard-bypassing relays, so the relay uses the runtime’s single connection rather than opening a second one — which spares a client under the Greengrass shared-connection quota. This does not make arbitrary bytes part of the relay contract: edgecommons UNS messages are protobuf EdgeCommonsMessage bytes, and the bridge decodes, mutates relay metadata, and re-encodes that envelope. Foreign/non-protobuf payloads on these paths are dropped as malformed.

The elegant consequence: because the bridge’s own state/cfg/metric traffic goes out on that shared connection and matches the relay’s own uplink filters, the bridge is relayed to the site broker by itself. The site sees the bridge exactly as it sees any other component — no special-casing — plus the one thing only the bridge sets: the private site-connection Last-Will.

The relay matrix — what crosses, and which way

Section titled “The relay matrix — what crosses, and which way”

The relay is topic-verbatim: a forwarded message is republished to the identical topic string on the other connection. The protobuf envelope is decoded and re-encoded only when the bridge appends its hop tag or rewrites reply metadata; body content, including opaque body bytes, is not interpreted. What crosses is a fixed matrix of UNS classes.

flowchart LR
  comp["device components"] -->|"state cfg evt metric data log (app opt-in)"| B((uns-bridge))
  B -->|"topic-verbatim + hop tag"| SITE["site broker"]
  SITE -->|"cmd (own device only)"| B
  B -->|"topic-verbatim + hop tag"| comp
  • Uplink (device → site) relays the six consumer classesstate, cfg, evt, metric, data, log — the same six wildcards a fleet consumer subscribes. A seventh class, app, is opt-in (default off; off also means its filter is never even subscribed). cmd is never uplinked — there is no cross-device request/reply, so the only requests crossing the bridge originate on the site side.
  • Downlink (site → device) relays cmd only, and only for this bridge’s own device — the downlink filter is pinned to ecv1/{device}/+/+/cmd/#. A bridge must pull down only commands addressed to its device, which is also exactly the scope its site-broker ACL grants it.

That the uplink set and the downlink set are disjoint is not incidental. A cmd the bridge relays down onto the device bus can never match an uplink filter, so a single bridge can never echo a message back to where it came from; the hop tag handles envelope cycles across distinct bridges.

Why re-check the class in the engine when the subscription filters already constrain what arrives? Because the decision surface should be self-contained and testable without a broker, and because a misconfigured broker-side ACL could deliver something unexpected. The engine is a pure function — topic + payload + direction in, a Forward(bytes)/Drop(reason) verdict out, no IO, no clock — which is why the whole routing/pinning/loop-protection surface is unit-tested exhaustively against fakes.

The site broker is not a dead end: a message relayed up could, in a multi-site or misconfigured topology, find its way back to a device bus and be relayed again. The bridge stamps every relayed protobuf envelope with a reserved tag, tags._relay. It is normal envelope metadata; diagnostic JSON renders it as an array of hop ids, each {device}/uns-bridge. Before forwarding, it applies three rules:

  1. Drop-if-self — if the array already contains this bridge’s own hop id, drop silently (own echo).
  2. Drop at maxHops — if the array already holds maxHops ids (default 4), drop. This is defense against a cycle among distinct bridges, where drop-if-self never fires on the first lap.
  3. Otherwise append this bridge’s id and forward, envelope otherwise semantically identical.

Two subtleties worth internalizing. First, envelope tags are orthogonal metadata: _relay is not payload content, and opaque application bytes stay in body. Second, the _ prefix on _relay is the library’s reserved-tag convention; a non-conforming relay that wrote a non-list _relay is tolerated by normalizing it (the maxHops cap still bounds any residual cycle). Consumers ignore _relay, but it doubles as a “which path did this message take” breadcrumb. Payloads that cannot decode as protobuf edgecommons envelopes are dropped as malformed, not forwarded without a hop tag.

Request/reply across the bridge — the correlation map

Section titled “Request/reply across the bridge — the correlation map”

Fire-and-forget commands relay untouched. Request/reply breaks without help, because header.reply_to names a topic on the site broker (typically an ephemeral edgecommons/reply-<uuid>), and a device-side responder replying onto the device bus would shout into a room where the requester isn’t standing.

The bridge proxies the reply path:

  1. Down — a relayed cmd carrying header.reply_to gets a bridge-minted reply topic (edgecommons/reply-<uuid>, the core’s standard prefix, so it is indistinguishable from any other reply topic and structurally exempt from the reserved-class guard) written into its header. The bridge subscribes that topic on the device bus first, then relays the rewritten command, and records bridge topic → original site reply topic in a TTL’d correlation map. Subscribing before publishing closes the race where a fast responder replies before the subscription is live.
  2. Up — the first protobuf message on a bridge reply topic is decoded, relayed to the original site reply_to, and re-encoded with two touches: the hop tag is appended (“a reply is a relay like any other”) and header.reply_to is dropped (a reply carries none, and a device-bus topic would be meaningless at the site). The correlation entry is then removed and the bridge topic unsubscribed — one-shot, first-reply-wins.
  3. TTL & bound — entries expire after reply.ttlSecs (default 60 s), and the map is bounded by reply.maxPending (default 1024), evicting the oldest on overflow so a stuck responder can’t starve fresh traffic. Both an expiry and an eviction unsubscribe the bridge topic and count relay_reply_expired. A reply that arrives after its entry is gone is a stray — dropped and counted.

The TTL default is deliberately the framework’s 30 s request-deadline default (messaging.requestTimeoutSeconds): the bridge must never tear down a reply path before the requester’s own deadline has settled it. This makes them a paired knob — raise requestTimeoutSeconds and you must raise reply.ttlSecs in step.

When the site link drops, or a site publish fails (the two are treated identically — a failed publish is a disconnect), the per-class uplink policy decides each message’s fate:

  • Everything except evt drops and is counted dropped_disconnected. This is the “live path is not durable” rule made concrete: telemetry lost during an outage is gone, because durability belongs to the streaming subsystem, not the bus.
  • evt buffers. Events and alarms ride a bounded, memory-only, drop-oldest replay buffer (bufferWhileDisconnected, default on, 1000). A WAN blip must not lose an alarm raise or clear. On reconnect a watcher task replays the buffered events to the site broker strictly in order (they are the already-hop-stamped forward bytes, so topic-verbatim and loop rules still hold); overflow while down evicts the oldest, counted evt_buffer_dropped. To preserve intra-evt ordering, a live evt that arrives while older ones are still queued joins the queue rather than overtaking them.

“Memory-only” is a deliberate scope call: it survives a WAN blip, not a bridge restart. Durable, restart-safe event capture is, again, the streaming subsystem.

Because the UNS is not retained (no MQTT retained messages), a consumer that connects after a device announced its state/cfg would otherwise see nothing until the next natural re-announce. On the rising edge of a site reconnect, the bridge publishes two notification-style broadcasts on the device busecv1/{device}/_bcast/main/cmd/republish-state and …/republish-cfgbefore it replays the evt buffer. The _bcast pseudo-component rides the + component position of the downlink filter, so every device component re-announces its state keepalive and effective cfg, which then ride the uplink so the site view rehydrates without retain.

Each device component answers by re-announcing its state keepalive and effective cfg. Answering is built into the edgecommons library (the four-language device-side RepublishListener), on by default — components need no wiring. A reconnecting bridge’s rehydration completes automatically, without relying on broker retain.

Nothing about the bridge’s health is bespoke. Its heartbeat publishes its state keepalive; its cfg publisher announces its redacted effective config; and every 30 s a task snapshots the relay counters and emits them through gg.metrics() on the UNS metric class (ecv1/{device}/uns-bridge/main/metric/<name>). All of it matches the uplink filters and is relayed by the bridge itself. Counters emit interval deltas (so they sum correctly in CloudWatch/EMF); two gauges — relay_pending_replies and site_connected — emit current values. See reference/metrics.md for the full table.

The site Last-Will is deliberately private to the bridge-console contract. At startup the bridge derives its real state topic from the resolved runtime identity (gg.uns().topic(State)) and registers a protobuf EdgeCommons state envelope with status:"UNREACHABLE" on that topic with QoS 1 on the site broker. There is no lwt knob to set; a configured component.instances[site].lwt is rejected so a typo cannot silently break console reachability.

  • HOST — the primary target. Device bus and site bus are both MQTT brokers; the bridge is a plain binary run with the standard args --platform HOST --transport MQTT <config> -c FILE <config> -t <thing>. This is what the tutorial and the e2e test exercise.
  • KUBERNETES — the same binary deployed as a boundary bridge between an on-prem device bus and an in-cluster aggregation broker. It must be replicas: 1 + strategy: Recreate — exactly one bridge per device bus (two would double-deliver everything; the hop tag prevents loops, not duplicates). Note the asymmetry: there is no bridge inside a cluster — the in-cluster broker is itself the aggregation point; a bridge only appears at a boundary.
  • GREENGRASS — on a Greengrass core the device bus is the Nucleus IPC pubsub and the site half is MQTT. The bridge shares the runtime’s IPC provider as its relay PRIMARY, so the same relay serves IPC and MQTT device buses alike. This build needs the greengrass cargo feature (a Linux-only C-FFI IPC provider, layered on standalone for the site MQTT provider); the packaging recipe.yaml runs it with --platform GREENGRASS --transport IPC -c GG_CONFIG -t {iot:thingName} and grants IPC pubsub accessControl for the local UNS topics. (The site broker’s own Greengrass deployment recipe under deploy/site-broker/greengrass/ runs the broker, not a bridge inside a core.)

The relay runs at the raw provider level with no in-process publish guard — by design (it must forward other components’ reserved-class protobuf publishes). That means the site broker’s per-device ACL is the security boundary, not any code in the bridge: an ACL that lets each bridge publish only under its own ecv1/{device}/# subtree (and read only its own cmd) is what actually contains a compromised or misconfigured device. Deploy the bridge only against an ACL-enforcing (and, in production, mTLS) site broker; an ACL-less site broker has no boundary at all. The deploy/site-broker/ recipe set ships exactly this — a per-device acl.conf, server/client certs, and the compose/k8s/Greengrass manifests to run the broker as the bridge’s paired half.

  • It does not transform payloads, re-key topics, or filter by content — it is a relay, not an ETL step.
  • It does not uplink cmd or support cross-device request/reply.
  • It does not make the live path durable — that is the streaming subsystem.
  • It does not run inside a Kubernetes cluster (only at a boundary) — but it does run on a Greengrass core over Nucleus IPC as the primary device bus.
  • It does not enforce the site security boundary in code — the site broker’s ACL/mTLS does.