Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3e00979
feat(aggregation): address a grouping entity's resources through one …
bburda Aug 20, 2026
2ecaa4b
feat(aggregation): retain a silent peer's declarations as unreachable
bburda Aug 20, 2026
f4a3cbc
fix(aggregation): a refresh that could not read a peer is not a pictu…
bburda Aug 20, 2026
1308fc1
fix(operations): refuse an id that names more than one operation
bburda Aug 20, 2026
b83ac3d
feat(aggregation): serve a member-qualified request on the gateway th…
bburda Aug 20, 2026
572a681
fix(aggregation): carry the caller's identity to the gateway that jud…
bburda Aug 20, 2026
5ebcce6
fix(operations): address an operation by its ROS path where one provi…
bburda Aug 20, 2026
2cbf1ab
fix(configurations): decide the member half by membership, not by a n…
bburda Aug 20, 2026
ea062c9
fix(operations): resolve an operation id the same way on every route
bburda Aug 20, 2026
df92fc4
fix(aggregation): carry an entity's unreachability across every hop
bburda Aug 20, 2026
0fc6388
fix(operations): reach a peer-owned execution through the aggregate
bburda Aug 20, 2026
b2e8713
test(triggers): start the second gateway when the trigger exists
bburda Aug 20, 2026
e08e67f
fix(subscriptions): refuse a resource path the collection cannot stream
bburda Aug 20, 2026
0e559b6
fix(triggers): keep a restored trigger until its entity has been seen…
bburda Aug 20, 2026
9784473
fix(subscriptions): keep the sampler declaration inside the gateway
bburda Aug 20, 2026
46c214c
test: wait for a mock peer to listen before handing back its port
bburda Aug 20, 2026
3aa545d
test(aggregation): give the grouping suite a budget its own waits fit…
bburda Aug 20, 2026
b6a49d3
docs(openapi): name every form an operation id can take
bburda Aug 20, 2026
281ba79
fix(aggregation): read a peer item's member and its availability from…
bburda Aug 20, 2026
bf7bf4c
fix(triggers): let a restored trigger keep resolving as long as its r…
bburda Aug 20, 2026
15a3616
fix(data): let a bare topic id find the gateway that owns it
bburda Aug 20, 2026
b5d18f3
test(aggregation): prove a peer that comes back is taken back
bburda Aug 20, 2026
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
375 changes: 365 additions & 10 deletions docs/api/rest.rst

Large diffs are not rendered by default.

112 changes: 112 additions & 0 deletions docs/config/aggregation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,16 @@ Combine static peers for known infrastructure with mDNS for dynamic discovery:
configuration. See :ref:`Security Parameters <aggregation-security>`
for details on securing peer communication.

.. note::

``X-Client-Id`` is always forwarded, and is not governed by
``forward_auth``. It names the caller rather than granting it anything:
a lock on a peer-owned entity is held on the peer and judged there, so a
forwarded request that arrived without the name would be a different
caller than the one holding the lock. Authority still travels only in
``Authorization``, which is forwarded when the deployment says the peer
is trusted with it.

Secure Aggregation (TLS + Auth)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down Expand Up @@ -355,6 +365,52 @@ When aggregation is enabled, entities from peers are merged with local entities:
Requests for remote entities are transparently forwarded to the owning peer.
The routing table maps entity IDs to peer names.

An entity that draws its resources from members - an Area, a merged Function, a
hierarchical parent Component - is deliberately absent from that table, because
its members can sit on different gateways and routing it whole would discard
every member the other contributors hold. A request that names one member is
routed instead: it is re-addressed to that member's own entity route on the
gateway that owns it, so

.. code-block:: text

POST /api/v1/functions/vehicle_health/operations/peer_calibration:calibrate/executions

becomes, on the peer that runs ``peer_calibration``,

.. code-block:: text

POST /api/v1/apps/peer_calibration/operations/calibrate/executions

The same routing applies to a single ``/data`` item and to a single
``/configurations`` parameter. A configuration id is ``<app_id>:<param_name>``,
and on the owning gateway the parameter is addressed by its bare name, so

.. code-block:: text

PUT /api/v1/functions/vehicle_health/configurations/peer_calibration:calibration_offset

becomes

.. code-block:: text

PUT /api/v1/apps/peer_calibration/configurations/calibration_offset

A member half is recognised when the text before the first colon names a member
of the addressed entity, so the qualified form works on an aggregating entity
whose members are all owned by peers - a parent gateway that runs no ROS node of
its own - and on one that runs a single node beside peer-owned members. Neither
shape changes the ids the entity's listing offers.

``DELETE /api/v1/{entity_type}/{id}/configurations`` resets the nodes this
gateway runs. A member another gateway runs is not reset by it, and the response
says so: ``207`` instead of ``204``, with that member named and the gateway that
owns it named with it.

Reachability is answered before anything is forwarded, so a member whose gateway
is silent gets ``504 not-responding`` rather than a ``502`` from a failed
connection. A member this gateway owns is served here, unchanged.

See :doc:`../design/ros2_medkit_gateway/aggregation` for detailed merge logic
and architecture diagrams.

Expand Down Expand Up @@ -389,6 +445,62 @@ action. Individual entity requests for remote entities (e.g.,
``GET /api/v1/apps/{id}``) return ``502 Bad Gateway`` if the owning peer
is unreachable.

Peer Refresh Completeness
~~~~~~~~~~~~~~~~~~~~~~~~~

A cache refresh reads a peer over several requests: the four entity lists, the
nested ``subareas`` and ``subcomponents`` collections, the per-entity detail
that carries a Component's relationships and a Function's hosts, and each app's
``operations``. If any of them cannot be read - connection failure, a status
the route has no other meaning for, an oversized body, unparsable JSON - the
refresh for that peer is discarded whole. A partial picture is never published
as a complete one, and the peer's last complete declaration is left in place.

What clients see then depends on the peer's health check:

- Health check fails: the retained declaration is served with
``x-medkit.available: false`` (and ``x-medkit.is_online: false`` for Apps),
because a request addressed there cannot arrive.
- Health check passes: the retained declaration is served unchanged and the
incomplete refresh is logged at ``WARN``. Availability is untouched - the
peer can still be reached; this gateway merely failed to read all of it.

A peer that starts answering again is read again on the next refresh, and a
retained declaration is replayed only for a peer that could not be read - so
the live answer replaces the retained one rather than being merged beside it.
``x-medkit.available`` clears on that refresh, and the entities the peer only
discovered at runtime, dropped while it was silent, are merged again with it.
``x-medkit.is_online`` is the peer's own account of an App rather than a
statement about the link, so after a gateway restart it stays ``false`` until
that gateway has relinked its ROS graph.

Two statuses are read rather than treated as failures:

- ``404`` on a nested collection route means the peer runs a gateway version
that does not expose the route. Those members are omitted, the rest of the
peer merges normally, and the absent routes are logged once per refresh at
``WARN``.
- ``504`` with error code ``not-responding`` on any route hanging off an entity
- its detail, or one of its nested collections - means the peer holds that id
and the gateway contributing it has gone quiet, which is the answer an
aggregating peer gives for a declaration it is retaining. In a chain topology
this is how the far end reports a dead leaf, so the entity is kept as the
peer's list named it and marked ``x-medkit.available: false``. A nested
collection answering that way costs only the members that route carries;
treated as a failure it would discard the whole peer on every refresh, so one
unreachable member would freeze this gateway's view of everything that peer
holds. A ``504`` without ``not-responding`` is not a statement about an entity
and still discards the refresh.

Availability is also read back off the wire. ``x-medkit.available`` is emitted
only when false, so an absent field means the entity is reachable, and that is
the default this gateway parses it with. It matters most beyond one hop: an App
also carries ``x-medkit.is_online``, but a Component has no second signal, so
without the read-back the head of a three-gateway chain reports a leaf behind a
dead gateway as reachable. Retention never contradicts what a peer said - it
only ever sets ``available`` to false, and it does so when the peer itself
stopped answering, which already covers everything behind it.

.. _aggregation-breaking-changes:

Breaking Changes (Entity Model Simplification)
Expand Down
4 changes: 4 additions & 0 deletions docs/tutorials/graph-provider.rst
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,10 @@ resource, so a client can receive periodic graph snapshots over Server-Sent
Events instead of polling. See :doc:`/api/rest` for the general
cyclic-subscription API; the graph-specific parts are below.

Each tick carries the whole graph document for the function. The resource URI
must therefore end at ``x-medkit-graph`` - appending a path below it is refused
with 400.

Create the subscription:

.. code-block:: bash
Expand Down
9 changes: 7 additions & 2 deletions docs/tutorials/plugin-system.rst
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,11 @@ call ``fn(entity_id, resource_path)`` on each tick. The function must return
``tl::expected<nlohmann::json, std::string>``. See `Cyclic Subscription Extensions`_
for the lower-level registry API.

A sampler registered through the plugin context streams its whole collection on every
tick. A subscription whose resource URI names a single item of that collection is
refused with 400 ``x-medkit-invalid-resource-uri`` rather than accepted and answered
with everything, so the URI must end at the collection.

.. note::

The ``PluginContext`` interface is versioned alongside ``PLUGIN_API_VERSION``.
Expand Down Expand Up @@ -468,8 +473,8 @@ Plugins can extend cyclic subscriptions by registering custom resource samplers
and transport providers during ``set_context()``.

**Resource Samplers** provide the data for a collection when sampled by a subscription.
Built-in samplers (``data``, ``faults``, ``configurations``, ``updates``) are registered
by the gateway during startup. Custom samplers are registered via ``ResourceSamplerRegistry``
Built-in samplers (``data``, ``faults``, ``configurations``, ``logs``, ``updates``) are
registered by the gateway during startup. Custom samplers are registered via ``ResourceSamplerRegistry``
on the ``GatewayNode``:

.. code-block:: cpp
Expand Down
Loading
Loading