From c56c7486740d40fd914e15d3f8a820bc9fa84b89 Mon Sep 17 00:00:00 2001 From: breis Date: Thu, 16 Jul 2026 02:48:22 -0400 Subject: [PATCH 1/3] docs(d-u28): component-scope UNS topics (retire `main`) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align file-replicator with the org-wide D-U28 UNS change: the instance token is optional and the `main` sentinel is retired. Component-level traffic (the command inbox, the `state` keepalive, component metrics, and component-level events) is now component-scope and carries no instance segment; per-instance events keep their real `component.instances[].id`. Docs + code-comment accuracy only — no functional code change. Drops `/main` from every UNS topic string and rewords `("main" instance)` phrasing to `(component scope, no instance token)`. The fleet-consumer subscription example is corrected for the new level count (component-scope `state`/`metric`/component-level `evt` lose the instance `+`). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01628gPY2czyDFVkkeEdkRZr --- DESIGN.md | 2 +- docs/how-to-guides.md | 18 ++++++++++-------- docs/reference/configuration.md | 2 +- docs/reference/data-types.md | 2 +- docs/reference/messaging-interface.md | 18 ++++++++++-------- docs/reference/metrics.md | 2 +- docs/tutorial.md | 2 +- src/app.rs | 4 ++-- src/control.rs | 10 +++++----- src/events.rs | 4 ++-- 10 files changed, 34 insertions(+), 30 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 5165df8..84a97b8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1043,7 +1043,7 @@ the UNS core this validity guarantee is enforced by the library that mints the t > **Superseded by the UNS-core migration (see §1 status banner) — authoritative: `docs/reference/messaging-interface.md`.** > The hand-rolled local control dispatcher below was replaced by verb registrations on the edgecommons -> **command inbox** (`gg.commands()`, `ecv1/{device}/FileReplicator/main/cmd/#`), which now provides the +> **command inbox** (`gg.commands()`, `ecv1/{device}/FileReplicator/cmd/#`), which now provides the > generic control framework core previously lacked. Concretely: **`get-config` is retired** (the library's > built-in `get-configuration` verb answers it, redacted — plus `ping` / `reload-config`); the > instance-in-topic scoping (`…/cmd/instances/{id}/…`) became an instance **body field** diff --git a/docs/how-to-guides.md b/docs/how-to-guides.md index 8b27245..373e70e 100644 --- a/docs/how-to-guides.md +++ b/docs/how-to-guides.md @@ -163,7 +163,7 @@ Pause or resume one instance at runtime without a redeploy, via the `set-activat "all" form — `instance` is required): ``` -request topic ecv1//FileReplicator/main/cmd/set-activation +request topic ecv1//FileReplicator/cmd/set-activation header.name set-activation body { "instance": "plant-csv-to-s3", "active": false, "persist": true } reply body { "ok": true, "result": { "instance": "plant-csv-to-s3", "active": false, "persisted": true } } @@ -203,7 +203,7 @@ Both the event `context` shapes and the `get-status` document schema are in the ## Monitor replication throughput and backlog Route metrics with the standard `metricEmission` section. With `metricEmission.target: "messaging"`, the -library publishes every metric group to `ecv1/{device}/FileReplicator/main/metric/{metricName}`; with +library publishes every metric group to `ecv1/{device}/FileReplicator/metric/{metricName}`; with CloudWatch or Prometheus, the same groups and dimensions are used by those targets. Key groups: @@ -226,17 +226,19 @@ errors as dimensions; those are intentionally kept in events/status documents, n ## Bridge status to the cloud for fleet-wide visibility Every device publishes under the same fixed UNS grammar -`ecv1/{device}/FileReplicator/{instance}/{class}[/…]`, so a single cloud-side consumer sees every device -with one subscription: +`ecv1/{device}/FileReplicator/[{instance}/]{class}[/…]` — the instance segment is present only for +per-instance events and absent for component-scope traffic — so a small cloud-side subscription set sees +every device: ``` -ecv1/+/FileReplicator/+/evt/# # every event, every instance, every device -ecv1/+/FileReplicator/+/state # the library RUNNING/STOPPED keepalive per device -ecv1/+/FileReplicator/+/metric/# # compatibility and operational metrics +ecv1/+/FileReplicator/evt/# # component-scope events (component-ready, scope-"all") +ecv1/+/FileReplicator/+/evt/# # per-instance events, every instance, every device +ecv1/+/FileReplicator/state # the library RUNNING/STOPPED keepalive per device +ecv1/+/FileReplicator/metric/# # compatibility and operational metrics ``` `{device}` is the ThingName (`-t`), `{component}` is the short UNS token `FileReplicator`. There is no configurable prefix and no legacy alias. To fold status into a fleet dashboard, bridge these topics -northbound and fan `get-status` requests to each device's `main` inbox as needed. Envelope `tags` (e.g. +northbound and fan `get-status` requests to each device's command inbox as needed. Envelope `tags` (e.g. `enterprise`/`site`) travel in the protobuf envelope for grouping — they are **not** topic segments. See the [messaging interface reference](reference/messaging-interface.md). diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index c5fb6aa..c7ea6ff 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -14,7 +14,7 @@ The component owns the `component` section (`component.global` + `component.inst `metricEmission` routes the compatibility `fileReplicator` metric group and the richer `FileReplicatorDiscovery`, `FileReplicatorQueue`, `FileReplicatorTransfer`, `FileReplicatorDestination`, and `FileReplicatorSchedule` groups. With `target: "messaging"`, the -library publishes to the reserved UNS `metric` class (`ecv1/{device}/FileReplicator/main/metric/{name}`); +library publishes to the reserved UNS `metric` class (`ecv1/{device}/FileReplicator/metric/{name}`); with CloudWatch or Prometheus, the same group names and bounded dimensions are used there. See [Reference - Metrics](metrics.md). diff --git a/docs/reference/data-types.md b/docs/reference/data-types.md index b850c5a..00ca4e2 100644 --- a/docs/reference/data-types.md +++ b/docs/reference/data-types.md @@ -14,7 +14,7 @@ unsigned 64-bit (a JavaScript consumer may lose precision above 2^53). Timestamp ## The `get-status` document -`get-status` (`…/main/cmd/get-status`) returns different shapes depending on the request body: +`get-status` (`…/cmd/get-status`) returns different shapes depending on the request body: - **`{}`** (no `instance`) → a **component-wide** document: a roster of every instance plus a summary. - **`{ "instance": "" }`** → that one instance's **per-instance** document (or its **disabled** document diff --git a/docs/reference/messaging-interface.md b/docs/reference/messaging-interface.md index fdeddac..2f2b5a0 100644 --- a/docs/reference/messaging-interface.md +++ b/docs/reference/messaging-interface.md @@ -8,16 +8,17 @@ Full rationale in [`DESIGN.md`](https://github.com/edgecommons/file-replicator/b page and the `crate::events`/`crate::control` module docs are the source of truth for the message contract. ``` -ecv1/{device}/{component}/{instance}/{class}[/{channel…}] +ecv1/{device}/{component}/[{instance}/]{class}[/{channel…}] ``` - `{device}` — the resolved ThingName (`-t`/`--thing`). - `{component}` — the SHORT UNS token edgecommons derives from the full name (the segment after the last `.` — `com.mbreissi.edgecommons.FileReplicator` → **`FileReplicator`**), not the `file-replicator` registry slug. -- `{instance}` — a `component.instances[].id`, or `main` for component-level traffic (the built-in - command verbs, `ComponentReady`, the scope-`"all"` `trigger`/`get-status` events, and the library's own - `state`/`cfg`/`metric` keepalives). +- `{instance}` — OPTIONAL. Present (a `component.instances[].id`) for instance-scoped traffic — a + replication instance's own events (`file-ready`, `replication-*`, …) ride `gg.instance(id).events()`. + **Absent** for component-scope traffic: the built-in command verbs, `ComponentReady`, the scope-`"all"` + `trigger`/`get-status` events, and the library's own `state`/`cfg`/`metric` keepalives. - `{class}` ∈ `cmd` (inbound commands, request/reply) · `evt` (event stream) · the **reserved**, library-owned `state`/`cfg`/`metric`/`log` (this component never publishes to them directly). @@ -26,8 +27,9 @@ There is no configurable topic prefix and no legacy alias — the UNS grammar ab ## Commands (`cmd`, request/reply via `reply_to`) -Registered on the single `main`-instance command inbox (`ecv1/{device}/FileReplicator/main/cmd/#`) — -scoping an instance is a request-body field, not a topic segment (mirroring how opcua-adapter/ +Registered on the component-scope command inbox (`ecv1/{device}/FileReplicator/cmd/#`); an instance token +is optional and present only for explicit multi-instance addressing. Scoping an instance is a request-body +field, not a topic segment (mirroring how opcua-adapter/ modbus-adapter address their multi-instance `sb/*` verbs and telemetry-processor's `route`/`pause`/ `resume`). Publish commands with the edgecommons client APIs (`MessageBuilder` + `MessagingService` request/reply, or an equivalent protobuf producer), not by sending JSON text to MQTT. Every decoded reply @@ -83,7 +85,7 @@ again. See **Permission handling** in `explanation.md`. Governed by `onPermissio ## State / heartbeat — library-owned The UNS `state` class is **reserved** (library-owned; an app-level publish to it is rejected) and carries -only the library's own `RUNNING`/`STOPPED` keepalive (`ecv1/{device}/FileReplicator/main/state`, on by +only the library's own `RUNNING`/`STOPPED` keepalive (`ecv1/{device}/FileReplicator/state`, on by default, 5 s, best-effort `STOPPED` on shutdown). The component publishes no `state` snapshot of its own, and none is retained (there are no retained MQTT messages; a timestamped app-layer cache on the consumer side is the substitute). The "current state on demand" path is **`get-status`** (a `cmd` verb, above), @@ -95,7 +97,7 @@ answers `get-status` — `active: false`, `disabled: true`, `disabledReason` — Metrics are emitted through `gg.metrics()` on the reserved UNS `metric` class, using the configured `metricEmission` target. The compatibility `fileReplicator` group and the richer -`FileReplicator*` groups publish under `ecv1/{device}/FileReplicator/main/metric/{metricName}`. +`FileReplicator*` groups publish under `ecv1/{device}/FileReplicator/metric/{metricName}`. For every metric's dimensions, measures, units, and diagnostic purpose, see [Reference - Metrics](metrics.md). diff --git a/docs/reference/metrics.md b/docs/reference/metrics.md index 74972a0..b3ffb9c 100644 --- a/docs/reference/metrics.md +++ b/docs/reference/metrics.md @@ -4,7 +4,7 @@ The file-replicator emits metrics through the EdgeCommons metric service. With `metricEmission.target: messaging`, metric messages are published on the reserved UNS `metric` class: ```text -ecv1/{device}/FileReplicator/main/metric/{metricName} +ecv1/{device}/FileReplicator/metric/{metricName} ``` The component does not publish directly to `metric` topics. It defines and emits metrics through diff --git a/docs/tutorial.md b/docs/tutorial.md index 2185f77..ecee607 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -106,7 +106,7 @@ filenames, bucket names, or raw errors. ## 7. Ask for status on demand There is no retained state snapshot; the current picture is a request/reply command. Send a protobuf -EdgeCommons command envelope to the `main` command inbox with `header.name = "get-status"` and a +EdgeCommons command envelope to the component-scope command inbox with `header.name = "get-status"` and a `reply_to` topic. In Rust, that is the normal `MessageBuilder` + `MessagingService::request` path: ```rust diff --git a/src/app.rs b/src/app.rs index e8b739c..749e40d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -170,7 +170,7 @@ impl App { "messaging unavailable; UNS control/event plane disabled (engine runs normally)" ); } - // The component-level ("main" instance) event emitter — `ComponentReady` and the control + // The component-level (component scope, no instance token) event emitter — `ComponentReady` and the control // plane's scope-`"all"` `ScheduleTriggered` (crate::control module docs). let main_events = Events::new(gg.events()); @@ -348,7 +348,7 @@ impl App { ); gg.set_ready(true); - // Component-ready lifecycle event (DESIGN §17.1), on the component-level ("main" instance) + // Component-ready lifecycle event (DESIGN §17.1), on the component-level (component scope, no instance token) // emitter. No-op when messaging is absent. main_events .emit(Event::ComponentReady { diff --git a/src/control.rs b/src/control.rs index 1fe8c5f..3fe2c52 100644 --- a/src/control.rs +++ b/src/control.rs @@ -2,7 +2,7 @@ //! //! **One-liner purpose**: The inbound half of the control plane — register `get-status` / `trigger` //! / `set-activation` on the edgecommons component **command inbox** (`gg.commands()`, -//! `ecv1/{device}/file-replicator/main/cmd/#`) and answer via the library's request/reply wrapping. +//! `ecv1/{device}/file-replicator/cmd/#`) and answer via the library's request/reply wrapping. //! The built-in `ping` / `reload-config` / `get-configuration` verbs (registered by the library) //! answer everything the old custom `cmd/config` verb used to. //! @@ -19,7 +19,7 @@ //! //! **Scoping — body field, not topic segment.** The old scheme split `cmd/status` (all instances) //! from `cmd/instances/{id}/status` (one instance) by TOPIC. The command inbox is one subscription -//! per component (bound to the `main` instance), so scoping now rides an optional `instance` field in +//! per component (component scope, no instance token), so scoping now rides an optional `instance` field in //! the request body — the same convention opcua-adapter/modbus-adapter use for their multi-instance //! `sb/*` verbs and telemetry-processor uses for `pause`/`resume`'s `route` field. `get-status`/ //! `trigger` omit `instance` for "all"; `set-activation` always requires it (it never had an "all" @@ -46,7 +46,7 @@ //! control plane does not hold a redundant one: it calls [`InstanceControl::notify`] on the SPECIFIC //! instance it already looked up, which emits through that instance's own bound //! [`crate::events::Events`]. The control plane keeps only its own component-level `Events` (bound to -//! `gg.events()`, the `main` instance) for scope-`"all"` events (`ScheduleTriggered{scope:"all"}`). +//! `gg.events()`, component scope, no instance token) for scope-`"all"` events (`ScheduleTriggered{scope:"all"}`). //! //! ## `state/…` — dropped (see `crate::events` module docs) //! The retained per-instance/component snapshot publish (`republish_after_transition`/ @@ -152,7 +152,7 @@ pub struct ControlPlane { config: Arc, store: Arc, instances: Vec>, - /// Component-level ("main" instance) event emitter — scope-`"all"` `ScheduleTriggered` only; every + /// Component-level (component scope, no instance token) event emitter — scope-`"all"` `ScheduleTriggered` only; every /// instance-scoped event is delegated to [`InstanceControl::notify`] instead (module docs). events: Events, /// Instances that never started (Feature A `onPermissionError: disableInstance`) — see @@ -161,7 +161,7 @@ pub struct ControlPlane { } impl ControlPlane { - /// Assemble the dispatcher. `events` is the component-level ("main" instance) emitter (e.g. + /// Assemble the dispatcher. `events` is the component-level (component scope, no instance token) emitter (e.g. /// `Events::new(gg.events())`) — see the module docs' "Instance-scoped events" note for why this /// is the ONLY emitter the control plane itself holds. pub fn new( diff --git a/src/events.rs b/src/events.rs index faac0d9..ef414f0 100644 --- a/src/events.rs +++ b/src/events.rs @@ -130,7 +130,7 @@ pub enum Event { InstanceActivated { source: String }, /// An instance was deactivated / reset (via the control plane). InstanceDeactivated { source: String }, - /// The component finished initializing (`set_ready(true)`). Component-level (main instance). + /// The component finished initializing (`set_ready(true)`). Component-level (component scope, no instance token). ComponentReady { instances: u64, version: String }, /// A trigger command forced a scan/replication, or a `cron` schedule fired (DESIGN §12.2: cron /// mode releases all ready work at each fire, reusing the same "the schedule released work now" @@ -461,7 +461,7 @@ pub struct Events { impl Events { /// Build an emitter bound to a live facade (`gg.instance(id).events()` for an instance, or - /// `gg.events()` for the component-level "main" instance). + /// `gg.events()` for component-level traffic — component scope, no instance token). pub fn new(facade: EventsFacade) -> Self { Self { facade: Some(facade), From 42b029085529f9dc59c8d92e78744b21b584b58e Mon Sep 17 00:00:00 2001 From: breis Date: Thu, 16 Jul 2026 08:17:19 -0400 Subject: [PATCH 2/3] feat(d-u28): bump edgecommons core to the D-U28 merge (36a70c48) Makes the built component emit component-scope topics (library heartbeat/facades/ command inbox on the D-U28 core drop the instance token). Flipped the one wire assertion: the component-scope get-status reply identity now has no instance (was `main`); per-instance emitters still carry Some(id). cargo test green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01628gPY2czyDFVkkeEdkRZr --- Cargo.toml | 2 +- tests/p3_control_emqx.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a4acf8b..619cc9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,7 +82,7 @@ dest-gcs = ["dep:reqwest", "reqwest/json", "dep:base64", "edgecommons/credential # the reserved-class guard, the `data()`/`events()`/`app()` class facades) plus the rebranded Rust crate # metadata. The pin below points at the current core `main`; LOCAL DEV may still build against the sibling # checkout via the gitignored `.cargo/config.toml` `paths` override, which replaces this source outright. -edgecommons = { git = "https://github.com/edgecommons/edgecommons.git", rev = "1c01ace09321d8f0e052e63ffb7c0a11aceafb94", default-features = false } +edgecommons = { git = "https://github.com/edgecommons/edgecommons.git", rev = "36a70c48b65b35f77bfab70d3a73869debdfc407", default-features = false } tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time", "sync", "fs"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/tests/p3_control_emqx.rs b/tests/p3_control_emqx.rs index b238683..5b95915 100644 --- a/tests/p3_control_emqx.rs +++ b/tests/p3_control_emqx.rs @@ -157,7 +157,7 @@ fn events_named(rec: &Recorder, wire_type: &str) -> Vec<(String, Message)> { .collect() } -fn assert_core_protobuf_round_trip(msg: &Message, thing: &str, instance: &str) { +fn assert_core_protobuf_round_trip(msg: &Message, thing: &str, instance: Option<&str>) { let bytes = msg.to_vec().expect("message encodes as EdgeCommons protobuf"); assert!( serde_json::from_slice::(&bytes).is_err(), @@ -323,8 +323,8 @@ async fn driving_replication_publishes_lifecycle_events() { assert_eq!(ready[0].1.body["context"]["size"], json!(size)); assert!(ready[0].1.body.get("timestamp").is_some()); assert_eq!(ready[0].1.identity.as_ref().unwrap().device(), thing); - assert_eq!(ready[0].1.identity.as_ref().unwrap().instance(), id); - assert_core_protobuf_round_trip(&ready[0].1, &thing, id); + assert_eq!(ready[0].1.identity.as_ref().unwrap().instance(), Some(id)); + assert_core_protobuf_round_trip(&ready[0].1, &thing, Some(id)); // replication-started body. let started = events_named(&rec, "replication-started"); @@ -393,7 +393,7 @@ async fn get_status_request_receives_reply() { assert_eq!(reply.header.name, "get-status"); assert_eq!(reply.identity.as_ref().unwrap().device(), thing); assert_eq!(reply.body["ok"], json!(true)); - assert_core_protobuf_round_trip(&reply, &thing, "main"); + assert_core_protobuf_round_trip(&reply, &thing, None); let result = &reply.body["result"]; assert_eq!(result["instance"], json!(id)); assert_eq!(result["active"], json!(true)); From dd4ef340f6ec79334dcdad94499bca93f5121ed2 Mon Sep 17 00:00:00 2001 From: breis Date: Thu, 16 Jul 2026 14:04:59 -0400 Subject: [PATCH 3/3] fix(d-u28): heartbeat config uses the current schema (drop legacy targets[]) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployed Greengrass baseline on lab-5950x caught this: the recipe's default ComponentConfig heartbeat still used the legacy `heartbeat.targets[]` array, which the D-U28 core config schema rejects (`Additional properties are not allowed ('targets' was unexpected) at /heartbeat`) — so the component failed initial config validation and never started. Move to the current schema `{enabled, intervalSecs, destination, measures}`. Re-verified on-device: config validates, and the republish listener, command inbox (both D-U28 scopes), and heartbeat all init cleanly over IPC. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01628gPY2czyDFVkkeEdkRZr --- recipe.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/recipe.yaml b/recipe.yaml index 0ecc0c2..8526789 100644 --- a/recipe.yaml +++ b/recipe.yaml @@ -19,9 +19,9 @@ ComponentConfiguration: level: "INFO" rust_format: "{timestamp} [{level}] [{component}] {target} - {message}" heartbeat: + enabled: true intervalSecs: 5 - targets: - - type: "metric" + destination: "local" measures: cpu: true memory: true