Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
18 changes: 10 additions & 8 deletions docs/how-to-guides.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<device>/FileReplicator/main/cmd/set-activation
request topic ecv1/<device>/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 } }
Expand Down Expand Up @@ -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:
Expand All @@ -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).
2 changes: 1 addition & 1 deletion docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/data-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<id>" }`** → that one instance's **per-instance** document (or its **disabled** document
Expand Down
18 changes: 10 additions & 8 deletions docs/reference/messaging-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions recipe.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 5 additions & 5 deletions src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//!
Expand All @@ -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"
Expand 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`/
Expand Down Expand Up @@ -152,7 +152,7 @@ pub struct ControlPlane {
config: Arc<Config>,
store: Arc<dyn StateStore>,
instances: Vec<Arc<dyn InstanceControl>>,
/// 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
Expand All @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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),
Expand Down
8 changes: 4 additions & 4 deletions tests/p3_control_emqx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Value>(&bytes).is_err(),
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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));
Expand Down
Loading