Skip to content

DTaaS M3: ORBIT-pubsub stream backend — data plane inside the token domain (closes R7) - #2

Merged
andre-merzky merged 13 commits into
develfrom
feature/dtaas-m3
Aug 24, 2026
Merged

DTaaS M3: ORBIT-pubsub stream backend — data plane inside the token domain (closes R7)#2
andre-merzky merged 13 commits into
develfrom
feature/dtaas-m3

Conversation

@andre-merzky

Copy link
Copy Markdown
Member

Migrated from BenCarter44#6 as part of the move to this org repo.
Same branch, same commits. Now stacked properly: base is feature/dtaas-m2, so the diff below is
this milestone's own delta rather than the cumulative stack.

Note: these branches predate the recent merges/additions on upstream main. A rebase of the
full stack onto current main already exists and is tested (158 unit / 29 integration green):
branch feature/dtaas-rebased. It replaces this stack once review here settles.

Milestone M3 (final) of the DTaaS v1 plan (docs/dtaas-v1-plan.md): the DT data plane moves onto ORBIT's token-authenticated eventing — closing the plan's R7 security risk for service deployments.

Stacked on #5 (M2) → #4 (M1) → #3 (M0) — review the commits from the M2 head onward, or merge in order.

What's in here

  • OrbitPubSubBackend (digitaltwin/streaming_orbit.py, lazy-imported): a second stream backend behind the abstraction M0 built. Publish = one participant event frame per message (EndpointRuntime.send_notification, a public bare-participant path); subscribe = exact-topic broker-side filtering; delivery = bounded drop-oldest inbox bridged onto the host loop — matching the DT conflation contract, not adding a second layer (all three bounded hops documented, drops counted and reported at close).
  • Deployment switch: DT_STREAM_BACKEND=zmq|orbit, resolved once at plugin construction (invalid value fails the deployment), applied in a single choke point — under orbit the embedded ZMQ broker subprocess is structurally never spawned. admin/sessions reports the active backend.
  • Security: under the orbit backend no DT-owned port exists — the data plane rides the broker's token-gated WebSocket ingress. The ZMQ backend remains for local/two-terminal use with its loopback-default + firewall policy. Oversized payloads (4 MiB frame domain) fail loudly at publish.
  • Shared scaffolding: the receive-loop/supervision machinery both backends need was hoisted from the ZMQ client into the base class (verified regression-free against the M0-hardened behavior); a latent wake-on-close gap in the wait path was fixed for both backends.
  • Tests: 126 total (97 unit + 29 integration) — the orbit path runs a second real broker with its own endpoint: full sensor→investigator→sink twin, the M2 learner retraining off the orbit stream, namespace isolation, churn with fd/participant leak assertions, and the R7 pin in its strongest form (no child processes of the plugin host after a twin has streamed).
  • Perf (perf/bench_streams.py): stream hop p50 0.98 ms (zmq) vs 2.09 ms (orbit) — ~1.1 ms per hop for the token-domain property, noise against a ~20 ms in-situ prediction.

Internally reviewed (approve; all findings applied). This completes the v1 milestone series: M0 hardening → M1 service plugin → M2 ROSE ex-situ learning → M3 secured data plane.

🤖 Generated with Claude Code

andre-merzky and others added 9 commits August 17, 2026 23:53
The DT streams carry cloudpickled payloads over ZMQ ports that nobody
authenticates: reaching them is code execution in every subscriber (plan
risk R7).  This puts the same payloads inside ORBIT's token-authenticated
star instead, behind the backend seam M0 built, so nothing above it
notices.

`OrbitPubSubBackend` publishes with `EndpointRuntime.send_notification`
and subscribes with `register_callback`, one registration per DT topic
under a single `dt_stream` plugin namespace -- ORBIT matches topics by
exact equality, which is what DT topics already are.  It owns one
participant connection per twin (rhapsody's `OrbitExecutionBackend`
pattern, loopback wrinkle and all), or rides an injected one.  Frames
cross from the runtime's callback thread to the host loop through a
bounded drop-oldest inbox -- the broker's own discipline, and the
conflation semantics the data plane is specified with.  Oversized
payloads raise at publish, because ORBIT would drop them with nothing but
a log line.

The two backends had grown the same receive loop, subscriber registry and
closed/running state, so those move up into `PubSubBackend`.

`DT_STREAM_BACKEND` selects the transport at deployment time.  Under
'orbit' the plugin's embedded ZMQ broker is never started at all -- the
`connect_stream` seam is the only caller of `stream_addresses`, so that
is structural rather than remembered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stand-in reproduces the three ORBIT behaviours the backend is written
against: exact-match subscriptions, fan-out that does *not* exclude the
sender, and callbacks arriving on a foreign thread.  The first of those
is load-bearing for DT -- a twin's runtime consumes the dtypes its own
persistent components publish, so a backend that never got its own events
back would deliver nothing at all.

Covers namespacing isolation on the second backend (same dtype label, two
twins, one broker), unsubscribe and re-subscribe, one wire subscription
per topic however many local subscribers, the payload ceiling, drop-
oldest under a stalled loop, and teardown: the participant is stopped, an
injected one is not, and a runtime that cannot register is not left
behind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A second deployment on the next port, started with
DT_STREAM_BACKEND=orbit -- separate because the transport is a
deployment-time choice and no client can ask for it.

The tests run whole twins on it: sensor to investigator to sink, the M2
learner retraining off the twin's input stream, two twins with the same
dtype label staying apart, inference unaffected, churn leaking no
participants.  The subscriber they attach is itself an ORBIT
participant, so even the observation path opens no port -- where the ZMQ
suite has to go and ask the service for addresses.

The R7 assertion is the direct one: the embedded stream broker is a
spawned subprocess, so "none was started" is "the plugin host has no
children", checked after a twin has actually streamed.  Plus the
oversized-payload refusal, which is the one place the 4 MiB frame cap is
visible to a user.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`bench_streams.py` times publish -> deliver through the same
`PubSubClient` on both backends, so the difference is the transport and
nothing else.  Loopback: 0.84 ms p50 on ZMQ, 1.94 ms on ORBIT, and about
a quarter of the burst throughput.

Roughly a millisecond per hop for the security property -- noise against
the ~20 ms of a single in-situ prediction.  Numbers in the perf README,
informational, not a gate.

Neither backend acknowledges a subscription, so the harness opens with a
round trip it retries until one comes back; without that barrier the
first publishes are simply lost and the measurement never starts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The framework's own pubsub ports authenticate nobody, so the data plane
was weaker than the control plane wrapped around it: reaching those ports
was code execution in every subscriber, token-free (risk R7).
`DT_STREAM_BACKEND=orbit` puts the same payloads on the token-
authenticated star instead, and starts no ZMQ broker at all -- there is
then nothing left to firewall.

Says so plainly, including the two things it does not do (per-tenant auth
is still post-v1; the 4 MiB frame cap is new and the ZMQ backend has
none), how a reviewer checks the guarantee, and that the ZMQ mitigations
remain in force for the deployments that keep it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`_await_running` parks a publish or subscribe that arrives before
connect() finished.  If the client is closed while somebody waits there,
the wait ends on a backend that has already let its transport go -- so
re-check, and raise the ordinary closed-client error instead of failing
somewhere further in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The docstring claimed two bounded queues on the receive path; there are
  three.  The middle one is ORBIT's own `CallbackDispatcher`, and it is
  the odd one out -- it drops the *newest* frame, not the oldest.  Name
  it, and log its counter alongside ours at close, so a twin that lost
  samples does not have to be inferred from `seq` gaps in somebody else's
  log.
- `_await_running` parked a caller that arrived before connect.  If the
  connect was then abandoned, `close()` merely cleared the flag and the
  waiter kept waiting for a connection that would never come.  Closing
  now sets the event before clearing it: the waiters wake, hit
  `_check_open`, and get the ordinary closed-client error.  Tested for
  both verbs that can park.
- The zmq branch of `connect_stream` re-read the environment per twin,
  which could contradict the choice the plugin resolved once at
  construction.  Name the backend.
- `frame_cap()` reaches into a private `EndpointRuntime` attribute; say
  so, and that the fix belongs upstream.
- The benchmark's barrier drained with an `empty()` check, which leaves a
  barrier message still in flight to arrive mid-measurement and pair with
  the wrong publish.  Wait for quiet instead.  Numbers re-measured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The M3 data plane added broker_url to PubSubConfig; the M0 round-trip
test compares the dict shape exactly and follows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A channel's payload is encoded by the codec its binding names, so the
transport has to hand those bytes over untouched -- the `raw` flag the
seam already carries.  The ZMQ backend honoured it; the ORBIT one did
not, so a twin binding a channel on that data plane failed at the first
subscribe.

The set of raw topics is the same notion in both backends, so it moves
up to PubSubBackend where the rest of the shared receive machinery
already lives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
andre-merzky and others added 2 commits August 19, 2026 11:17
The devel merge changed `PubSubBackend.unsubscribe` from `async def` to
`def`, and `PubSubClient` now calls it without `await`.  Two spots on
this branch were still on the old contract:

  * `OrbitPubSubBackend.unsubscribe` still declared `async def` -- its
    body is entirely synchronous -- so the client's plain call created a
    coroutine and dropped it, and the unsubscribe silently never
    happened ("coroutine ... was never awaited" in the test run).
  * the test awaited `unsubscribe_dtype`, which now returns None, so it
    died on a TypeError before it could even observe the first problem.

Both are the same mismatch, both fixed by dropping the keyword.

test_streaming_orbit: 17 passed (was 1 failed, 16 passed).  The two
remaining unit failures on this branch (`kind == "generic"` and the
describe() shape) are devel regressions, fixed on fix/devel-regressions;
they converge when that merges and devel flows down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@BenCarter44 BenCarter44 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great! Looked through it all and makes sense to me.

Ready to merge once #1 is merged.

@andre-merzky andre-merzky reopened this Aug 24, 2026
@andre-merzky
andre-merzky changed the base branch from feature/dtaas-m2 to devel August 24, 2026 10:24
M2/M3 bring integration tests importing `rose`.  PyPI's `rose` is an
unrelated version-string helper, so the real one is pinned like the
other radical deps, and the learn extra installs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@andre-merzky
andre-merzky merged commit 0d3d5e6 into devel Aug 24, 2026
2 checks passed
@andre-merzky
andre-merzky deleted the feature/dtaas-m3 branch August 24, 2026 10:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants