Skip to content

DTaaS M1: ORBIT service plugin (PluginDT/DTSession/DTClient), stacked on M0 - #4

Closed
andre-merzky wants to merge 19 commits into
BenCarter44:mainfrom
andre-merzky:feature/dtaas-m1
Closed

DTaaS M1: ORBIT service plugin (PluginDT/DTSession/DTClient), stacked on M0#4
andre-merzky wants to merge 19 commits into
BenCarter44:mainfrom
andre-merzky:feature/dtaas-m1

Conversation

@andre-merzky

Copy link
Copy Markdown
Contributor

Milestone M1 of the DTaaS v1 plan (docs/dtaas-v1-plan.md): the DT framework exposed as an ORBIT plugin — Digital Twins as a Service.

Stacked on #3 (M0): this PR's diff against main includes the M0 commits; review only the commits from 949da6d onward (or merge #3 first — this PR then shows the M1 delta alone). Merge order: #3 → this.

What's in here

  • digitaltwin.servicePluginDT (routes twin_create/twin_list/twin_close/twin_call/{sid} + admin/sessions; forced-persistent sessions; sid-as-bearer-capability reattach; supervised embedded stream broker, loopback random ports, respawn on the same reported addresses), DTSession (n twins per session; one shared 'task' engine via rhapsody's OrbitExecutionBackend, batch_window=0; wait_for-bounded shutdown; shielded session-owned engine builds), TwinInstance (initializing → ready → running → stopped | failed with last-error), DTClient (sync; public API mirrors the old 09-remote shape), wire (cloudpickle-base64 packaging with 4 MiB size check and a python/cloudpickle/digitaltwin version stamp, rejected server-side on skew, validated only after session resolution).
  • API semantics per the plan: async twin_create (client-supplied uuid → retry-idempotent; background init; twin_list polling as ground truth), all other verbs short sync, get_inference server-side bounded, stop terminal, idempotent no-ops for repeated lifecycle verbs, unknown-id twin_closeclosed.
  • Demo: test/09-service/ (sensor → agent → investigator-on-endpoint → sink, end to end); the old ZMQ REQ/REP prototype (src/digitaltwin/remote/, test/remote_service.py) is retired.
  • Tests: 76 total — 60 unit + 16 integration (real broker + two endpoints: co-located task endpoint and an endpoint-hosted dt smoke test; churn/fd-leak assertions, reattach-by-sid, crash→failed, close-with-in-flight-backend-task).
  • Perf: perf/bench_insitu.py checked in; re-measured 19.4 ms p50 / 334 tasks/s through the orbit endpoint (vs 11.6 ms / 35 tasks/s in-process pool) — the plan's §6 placement conclusion holds.
  • Docs: README service section (hosting recipe, client snippet, loopback/firewall binding policy, wire-contract notes incl. the current upstream limitation on function-task return values, fixed separately in radical.orbit).

Requires: radical.orbit ≥ devel@dc26036 (P2 notify-window knob) and the radical.asyncflow non-main-thread fix (radical-cybertools/radical.asyncflow#92). Both are open/merged upstream PRs; the service extra is optional — the package works without ORBIT installed.

Internally reviewed (approve); all nine review findings applied.

🤖 Generated with Claude Code

andre-merzky and others added 7 commits August 15, 2026 15:36
M0.2/M0.4 plus the transport half of M0.1/M0.5.

- PubSubClient takes a mandatory namespace; topics become
  `dt/<namespace>/dtypes/<label>` so twins which use identical dtype
  labels no longer cross-subscribe on a shared broker.  A topic
  terminator keeps a label from prefix-matching a longer one (ZMQ
  SUBSCRIBE is a prefix match; hygiene, not correctness).
- Teardown: ZMQ_PS_Client.close() cancels the receive loop, closes the
  sockets and terminates the context; PubSubClient.unsubscribe_dtype()
  and close() drop subscriptions and the backend with them.  The
  connect monitor is now attached before connecting (it only reports
  later events) and is explicitly closed -- disable_monitor() only
  detaches it, and the leftover socket blocked ctx.term() forever.
- ZMQ_Broker binds in the process that runs the proxy (a context does
  not survive spawn), defaults to a random loopback port and reports
  what it bound.  ZMQ_BrokerProcess embeds it as a spawn-context
  subprocess with async start/stop; the blocking spawn/join calls run
  off the event loop.
- New `config` module: the one place that decides addresses.
  Loopback-only defaults (R7), env overridable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
M0.1/M0.3/M0.5 in the runtime.

- DTRuntime.stop(): terminal, idempotent, per-twin.  Cancels every task
  the runtime owns (component main loops, callbacks, dtype consumers,
  barrier loops), waits for them with a bound, abandons what does not
  settle, then drops the twin's subscriptions and closes its stream
  client.  The shared engine is never touched.  In-flight backend
  tasks are cancelled best-effort through the awaiting task and
  abandoned after the timeout.  start() after stop() raises.
- All done-callbacks are now the one cancellation-safe _task_done,
  which routes component exceptions into runtime state (failed + last
  error) instead of the loop's exception handler.  RuntimeAPI's own
  background-task set is gone: it uses the runtime's task plumbing.
- RuntimeAPI exposes the injected, namespaced, connected stream client
  as `runtime.stream` -- persistent components publish through it and
  never build transport clients or see addresses.
- describe() returns a serializable graph/state summary; print_graph()
  is now just a rendering of it.
- Ride-alongs: awaited the coroutine in _internal_agent_inference,
  fixed WindowDataType.__eq__ comparing a field to itself, removed the
  dead _to_block machinery and the redundant truthy_list.  Barrier's
  loop is owned by the runtime (Barrier.run) instead of an untracked
  task with a result-raising callback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
M0.5/M0.6.  pyproject declared no dependencies at all; the versions
listed are the ones the framework is developed against (dist name
`rhapsody-py`).  `digitaltwin` now exports its public API, and pytest
(asyncio auto mode, tests under test/unit) plus tox are configured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
M0.6.  Covers stop being terminal/idempotent, component failure ->
failed state + last error, bounded abandon of tasks which ignore
cancellation, and a leak assertion (no lingering tasks, subscriptions,
sockets or contexts after teardown); namespace isolation of identical
dtype labels on one broker; persistent components publishing through
the injected stream client end to end, including two twins side by
side; and broker start/stop cycling with random ports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
M0.3/M0.6.  Persistent components are plain async main_loops which
publish through `runtime.stream`: the `@flow.function_task` wrapper and
the hand-built `ZMQ_PS_Client` inside every sensor are gone, and with
them the last address literals in demo code (a function task would have
been cloudpickled to a backend slot for the lifetime of the twin).
Every demo now opens one namespaced stream client via
`connect_stream_client()` and tears its twin down with `runtime.stop()`
before shutting the shared engine down.

`local_broker.py` and the demos resolve the broker addresses through
`digitaltwin.config`, so the two-terminal model still works and the
broker is configurable; the READMEs document the resolution and the
loopback binding policy.

Not migrated: test/rose_streaming (untracked in this repo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Guard against stopping a process whose start() failed, and reformat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fable review of M0.  Stability fixes, no new surface:

- The receive loop no longer dies on a bad payload or a raising
  subscriber: malformed messages are dropped and logged, and a failing
  subscriber does not starve its siblings.  Any other exit of that loop
  is reported through a new `PubSubBackend.on_error` hook which the
  runtime routes into `_record_error` -- a silently stalled stream is
  now a `failed` twin with a last error, not silence.
- `stop()` memoizes its teardown: concurrent and repeated callers await
  the same task and thus only return once the twin is really down
  (M1's `twin_close` idempotency for free).  The caller's cancellation
  does not abort it -- stop is terminal.
- `start()` on a failed twin raises, like `start()` after `stop()`.
- Graph mutation after stop raises instead of registering components
  whose tasks are then silently dropped.
- `ZMQ_BrokerProcess` serializes start/stop with a lock: concurrent
  starts can no longer spawn a second broker or observe (None, None).
- `PubSubClient` rejects namespaces containing the topic separator or
  the terminator -- the aliasing the namespace exists to prevent.
- `ZMQ_PS_Client.close()` closes sockets and terminates the context in
  a `finally`: a cancelled close can no longer leak them permanently
  behind the one-shot `_closed` guard.
- Dropped the instance assignment in `PubSubBackend.__init__` which
  shadowed each subclass's `label`, and the unexplained sleep in
  `connect()` (the monitor already confirms the connection).
- Broker cycling test no longer asserts distinct ephemeral ports
  (nondeterministic); it asserts valid ports and that a broker from a
  fresh cycle actually proxies.  `local_broker` prints with flush.

New tests: malformed payload / failing subscriber survival, receive
loop exit reporting, stream failure -> failed twin, concurrent stop,
stopped-graph mutation, namespace validation.  21 passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reviewed plan documents the milestone series (M0-M3) references;
PR descriptions point at docs/dtaas-v1-plan.md as the authoritative
spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Upstream review (Ben): handing components the live streamer is wrong for
anything that does not run in the host process -- the client owns
sockets, a receive loop and subscriber queues, none of which a remote
machine can reach.  This is the stream-endpoint descriptor the plan cut
in review round five, back in its minimal form.

- `PubSubConfig` is a frozen dataclass of plain fields (namespace,
  publish/subscribe address, backend kind) and therefore travels as
  pickle or as a dict.  `connect(timeout=None)` opens the backend for
  its kind, connects it bounded, and wraps it in a namespaced
  PubSubClient; a client which fails to connect is closed before the
  error propagates.  `resolve()` builds the config for the configured
  broker, and `connect_stream_client()` is now that plus connect.
- A backend declares which kind reopens it (`PubSubBackend.kind`, `zmq`
  here) and exposes the addresses a remote client would dial; a config
  for a foreign kind is refused rather than mis-opened.  The field is a
  plain string, so a second backend needs no change here.
- `PubSubClient.config` describes the client's own endpoint;
  `DTRuntime.stream_config` derives the twin's from the injected client
  (so it cannot go stale) and `RuntimeAPI` exposes both: `stream` stays
  exactly as it was -- the in-process convenience -- and `stream_config`
  is what a task ships when its code runs elsewhere.

Where shipping it off-host is documented, so is what it implies: the
broker has to be reachable from there, which for zmq means a
non-loopback bind on a firewalled network, not the loopback default.

API compatible: demos and tests are untouched except for new tests.
25 passing; demo 01 re-run live on non-default ports (env-resolved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`failed` meant "something broke and I recorded it": the twin's other
tasks kept running, its stream client kept its subscriptions, and the
whole lot stayed up until a client got around to closing the twin.  A
component failure is a twin failure -- the others have lost the graph
they were part of -- so it now runs the same teardown `stop()` runs and
ends in `failed` with `last_error`, not in `stopped`.

The traps this had to avoid:

- teardown cannot be awaited where the failure is seen.  `_record_error`
  is reached from synchronous done-callbacks (a component task, the
  stream backend's receive loop via `on_error`) and from inside the
  teardown itself, so it *schedules* one and keeps the handle.
- the teardown task must not be registered in `running_tasks`: teardown
  cancels that set, so it would cancel itself on its first await.
  `_start_teardown` creates it outside the set and consumes its
  exception, since nothing joins the teardown a failure started.
- one teardown only.  `_stop_task` is the single handle for both doors,
  so `stop()` on a failed twin joins the teardown already in flight
  (bounded by the same `STOP_TIMEOUT` the service would have granted)
  instead of starting a second one, and the state it finds is the state
  it leaves: `failed` sticks, which is the mirror image of the rule that
  a stopped twin stays stopped.
- the first error is the reported one.  Everything after it is fallout --
  teardown cancelling the failure's siblings, a stop hook tripping over a
  half-dead component -- and must not clobber the cause.
- `_to_asyncio_task` now refuses on a twin whose teardown is scheduled,
  not just on a stopped one: a task registered after teardown swapped
  `running_tasks` out would never be cancelled by anyone.

`test_component_failure_sets_failed_state` asserted the old contract
(`stopped` after an explicit stop of a failed twin) and is replaced by
`test_component_failure_tears_the_twin_down`, which makes the same leak
assertions the explicit-stop tests make.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@BenCarter44 BenCarter44 self-assigned this Aug 17, 2026
andre-merzky and others added 9 commits August 17, 2026 19:53
Sensors are external entities.  A sensor is not a twin component: it is a
process with a lifetime of its own, it precedes and outlives any twin,
and one instrument's data is wanted by however many twins care about it.
Persistent components can produce data, but they cannot be how data
enters a graph.

`DTRuntime.add_input(dtype, channel, codec='json')` binds a shared
external channel to an input dtype.  The channel topic goes on the
backend verbatim, with no `dt/<namespace>` prefix, which is exactly what
makes it shareable: n twins may bind the same channel and the pubsub
fan-out gives each of them every message.  A channel claiming the
internal prefix is refused, as is an unknown codec, and both are refused
at registration rather than on the first message.  From the dtype queue
onwards, external data is indistinguishable from internal traffic, so
consumers, barriers and investigators need no notion of where it came
from.  `stop()` drops the bindings with every other subscription, and
`describe()` reports them.

What goes on the wire is a deployment decision, not ours: `json` for the
plain scripts and instruments which are the normal producers, `raw` for
bytes, `cloudpickle` only for producers inside the same trust domain.  An
undecodable payload costs its own message and is logged, the same
contract malformed internal traffic already had.

The codecs and the verbatim subscribe live at `PubSubClient` level, above
the backend seam.  A backend learns one new thing, through the
`backend_params` it already accepted: `raw` payloads are opaque bytes
which it hands over untouched, because something above owns their format.
Nothing zmq-specific moved upwards, and nothing above knows a topic is a
zmq subscription.

`ChannelPublisher` is the other half, for code which is not part of the
framework at all: a broker, a channel, a codec, no namespace and no
runtime.  `PubSubConfig` grew `connect_backend()` for it and its
namespace became optional, since a channel belongs to no twin.

Demos 01 and 04 now run their sensor as a separate process publishing
JSON on a shared channel, and their twins bind it with `add_input`.  The
timer-driven demos (06, 07) stay as they were and are documented as the
internal-producer examples.  The README explains why the sensor gets a
terminal of its own.

Tests: two twins on one channel both receive every message, raw
delivery, an undecodable payload dropped without stalling the stream,
channel and codec validation, bindings in `describe()`, and codec round
trips.  40 passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records the revision agreed with the user in §1, adds the input-binding
item to M0 (item 7) and the verb to the §3 route list, and defers a
channel registry in §5.  Persistent tasks keep their place as internal
producers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
M0 follow-ups the service depends on:

- `ZMQ_PS_Client.connect(timeout=)` (and `connect_stream_client`) is
  bounded and closes the half-connected client on failure.  The twin's
  background initialization must fail into `failed` + a last error, never
  park in `initializing` because the stream broker is unreachable.
- `config.embedded_stream_addresses()`: an unconfigured *embedded* broker
  takes a random loopback port (it reports what it bound), where the
  standalone demo broker keeps the fixed default ports.
- `DTRuntime.get_inference()`: public entry to the inference path the
  service exposes as a verb, so it stops reaching into a private method.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`src/digitaltwin/service/` implements the plugin of the DTaaS plan's
section 3, registered out-of-tree via the `radical.orbit.plugins` entry
point (`--plugins default,dt`).

- `PluginDT`: forces `persistent` sessions and relaxes the owner check so
  the sid is a bearer capability (reattach by sid after a disconnect);
  owns the embedded DT stream broker -- one supervised subprocess shared
  plugin-wide, loopback by default, respawned on the addresses it
  reported; `admin/sessions` is the single admin route.
- `DTSession`: n twins plus the session-shared engines they run on (M1
  builds exactly one, `'task'`, an `OrbitExecutionBackend` with
  `batch_window=0` and `backends=['concurrent']` by default).  Teardown
  stops the twins, then bounds `flow.shutdown()` with `wait_for` -- a
  bare await would hang on asyncflow's unbounded gather.
- `TwinInstance`: `DTRuntime` + its own twin-id-namespaced stream client,
  with the `initializing -> ready -> running -> stopped | failed` state
  machine and a last error.
- API model: `twin_create` is the only asynchronous verb (client-supplied
  uuid, background init, returns `initializing`); everything else is a
  short request/response carrying exactly one graph verb.  `start` /
  `stop` / `twin_close` are idempotent no-ops on a twin already in that
  state; `twin_list` is the only observation mechanism.
- `wire`: cloudpickle-base64 payloads with a client-side size check
  against the 4 MiB frame cap and a Python/cloudpickle version stamp the
  service rejects on skew.
- The persistent-component guard warns when a component registered
  `function_task`s at instantiation and is then added as persistent --
  the one place where both facts are visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`test/09-remote` becomes `test/09-service` and drives the twin through
`DTClient` over ORBIT instead of the hand-rolled ZMQ REQ/REP prototype,
which is deleted along with its host script
(`src/digitaltwin/remote/`, `test/remote_service.py`).

`model.py` also splits its inference in two: the engine task returns a
plain value and the component wraps it in `TypedData`.  ORBIT's rhapsody
plugin cloudpickles task *arguments* but JSON-encodes return values,
stringifying anything that is neither JSON-safe nor bytes
(`plugin_rhapsody.py:547-556`) -- so a DT-typed object cannot come back
out of a task.  Noted in the demo and its README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Unit tests (no broker): forced-persistent sessions, sid-as-bearer
reattach, twin id and verb validation, the wire format's size and
version-skew guards, the persistent-`function_task` warning, and the
embedded stream broker -- including the supervisor respawning it on the
addresses it reported.

Integration tests spin a real stack as fixtures (ORBIT broker on 8031
hosting `dt`, co-located rhapsody endpoint with `backends=['concurrent']`
and the notify window at 0) and cover the plan's M1 item 9: inference
round trip, two concurrent twins whose identical dtype labels do not
cross-subscribe, independent teardown, twin churn with a leak assertion,
`twin_close` with an inference in flight, client disconnect and reattach
by sid, idempotent retries, start-after-stop and graph-verb-after-stop
errors, component crash surfacing as `failed` with a last error, the
admin listing, and an endpoint-hosted smoke test.  They skip when no
broker can be started.

Also: a closed twin now reports `closed` (keeping its last error) and
drops its runtime, and a rejected verb logs its traceback service-side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`perf/bench_insitu.py` is the section 6 benchmark, parameterized so it
runs against the integration stack (`--broker` / `--endpoint`, or the
usual ORBIT resolution).  Re-measured here: ~11 ms p50 in-process versus
~19 ms p50 through an ORBIT endpoint with the notify window at 0, and
~334 vs ~35 tasks/s under 50-way concurrency -- the placement decision
holds.

The README gains a service section: how to host the plugin
(`--plugins default,dt` plus a rhapsody endpoint), a client snippet, the
two contract notes users trip over (task return values must be JSON-safe
or bytes; persistent bodies are not function tasks), and the R7 binding
policy -- the embedded stream broker is loopback-by-default and a
non-loopback bind needs an explicit decision and a firewalled network.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- `test/unit/test_service.py` importorskips ORBIT and the integration
  conftest sets `collect_ignore_glob` when it is missing, so an install
  without the `service` extra still runs the framework suite.
- `twin_call` only converts a `CancelledError` into "twin was closed"
  when the handler itself is not being cancelled -- a host shutdown must
  keep propagating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nine findings from the M1 review, no behaviour the API promises changes.

Wire and validation:
- `twin_call` hands the payload to the session as an opaque blob and
  unpickles it only after `_forward` has established that the sid names
  a live session -- decoding is arbitrary code execution and must not
  happen for an unknown or expired one.
- The version stamp carries `digitaltwin` too, compared exactly:
  a shipped component class pickles the framework by reference, so the
  two sides have to be running the same code, not merely compatible
  pickle machinery.
- A hand-built payload with the wrong arity (`TypeError`) is a 409, and
  a call on a closed session is a 410 -- both were 500s.
- `get_inference` clamps a missing or nonsensical timeout to the
  default, so the service-side wait is literally always bounded.

Engines:
- The build is now a session-owned task that callers `shield`-await.  A
  twin whose initialization is cancelled halfway used to cancel the
  build with it and could strand a live `OrbitExecutionBackend` that
  `close()` never saw; a build landing after the session closed now
  shuts itself down.  `close()` raises the inactive flag first and
  drains outstanding builds briefly rather than waiting out a 150 s
  initialization.

Tests:
- The in-flight `twin_close` case runs twice: once waiting in the
  service, once on a real task on the endpoint, so the best-effort
  backend-cancel path is exercised.  That task's delay is deliberately
  short -- the cancel does not reach the endpoint, so a long one would
  hold a slot for the rest of the suite.
- The fd-leak assertion counts descriptors of *the* broker under test
  (pid from the fixture) and fails rather than skips when it cannot;
  the guard-warning assertion is scoped to its own twin id; `_spawn`'s
  log handles are closed on teardown.
- Unit coverage for all of the above, including engine-build
  cancellation and self-disposal.

Also: the twin-id uniqueness scan and the insertion it guards are
race-free only because nothing awaits between them -- pinned with a
comment at both halves.

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

Copy link
Copy Markdown
Owner

Done. Merged in feature/dtaas

@BenCarter44

Copy link
Copy Markdown
Owner

See branch feature/dtaas Closing this one.

@BenCarter44

BenCarter44 commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Actually, it's been merged to main. Vanilla framework moved to a release branch.

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