Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cfa3859
Namespaced topics, stream teardown, embeddable broker
andre-merzky Aug 15, 2026
655d1c6
Runtime lifecycle: terminal stop, failure state, describe()
andre-merzky Aug 15, 2026
b2925f8
Packaging: real dependencies, exports, pytest/tox scaffolding
andre-merzky Aug 15, 2026
78f6357
Unit tests for teardown, namespacing, streams and broker
andre-merzky Aug 15, 2026
34ffe75
Migrate the demos to the M0 API
andre-merzky Aug 15, 2026
f57123b
Harden the broker launcher stop path
andre-merzky Aug 15, 2026
1dbddfa
Review findings: stream resilience and join-safe teardown
andre-merzky Aug 15, 2026
d8e42f4
docs: add the DTaaS v1 plan and the P0 liveness-scoped-calls plan
andre-merzky Aug 16, 2026
2aba3e4
A stream endpoint a task can carry: PubSubConfig
andre-merzky Aug 17, 2026
1fe23d5
A component failure stops the twin
andre-merzky Aug 17, 2026
3fd5638
The graph opens at its input edge: external channels
andre-merzky Aug 17, 2026
09eb2ba
Plan: sensors are external, add_input is the entry path
andre-merzky Aug 17, 2026
5a9f639
Bounded stream connect, embedded broker addresses, public get_inference
andre-merzky Aug 15, 2026
7aee148
The dt ORBIT plugin: sessions, twins, client, wire format
andre-merzky Aug 15, 2026
116f9fb
Port the remote demo to the dt plugin, retire the ZMQ prototype
andre-merzky Aug 15, 2026
c5d0ab5
Tests for the dt plugin: units plus a live-stack integration suite
andre-merzky Aug 15, 2026
df7b208
Check in the in-situ latency harness and document the service
andre-merzky Aug 15, 2026
e7a7648
Keep the service tests skippable, and propagate real cancellations
andre-merzky Aug 15, 2026
471cf75
Review findings: decode later, bound harder, own the engine build
andre-merzky Aug 15, 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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,7 @@ __marimo__/

# Streamlit
.streamlit/secrets.toml

# runtime residue from asyncflow / rhapsody test runs
asyncflow.session.*/
telemetry-output/
137 changes: 131 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,136 @@ Not yet implemented:
- - Docs
- - Type Annotation

## Running tests:
## Running the unit tests:

1. `pip install -e .`
2. `cd tests/`
3. In one terminal, run `local_broker.py`
4. In second terminal, cd into the test and run `run_me.py`
1. `pip install .[test,service]`
2. `pytest` (or `tox` for all supported interpreters)

**When running tests: be sure to start the ZMQ PubSub broker!**
The unit tests start their own stream broker on a random port; no setup.
The integration tests under `test/integration` bring up a real ORBIT
broker and rhapsody endpoint and skip themselves when they cannot.

## Running the demos:

1. `pip install .`
2. `cd test/`
3. In one terminal, run `local_broker.py` -- it prints the addresses it
bound
4. In a second terminal, cd into the demo and run `run_me.py`
5. In a third terminal, in the same demo, run its `sensor.py` if it has
one (`01-start-inference-stop` and `04-start-agent-stop` do)

**When running a demo: be sure to start the ZMQ PubSub broker!**

The third terminal is the point, not an inconvenience: a sensor is an
external entity. It is a process of its own with a lifetime of its own,
it publishes JSON on a shared channel, and it knows nothing about twins.
The twin binds that channel with `runtime.add_input(dtype, channel)`, and
a second twin binding the same channel receives the same messages -- which
is how one instrument feeds many twins. Start and stop the sensor
independently of the twin; neither cares.

Demos without a `sensor.py` produce their input inside the twin, which is
what persistent components are still for: `06-agent-pi` drives itself off
a timer, and `07-barrier` off several.

Every side resolves the broker addresses the same way: `DT_STREAM_PUB_ADDR`
and `DT_STREAM_SUB_ADDR`, defaulting to `tcp://127.0.0.1:5000` and `:5001`
(see `digitaltwin.config`). Set them in every terminal to move the broker.

**Binding policy**: the broker binds to loopback by default, and it must
stay that way unless you know what you are doing -- twin-internal payloads
are cloudpickled, so anyone who can reach the broker ports can execute code
in every subscriber. A non-loopback bind needs an explicit configuration
and a private/firewalled network. External channels are decoded with the
codec their binding names: `json` (the default) and `raw` are safe to
accept from a producer you do not control, `cloudpickle` is not.

## Running it as a service (the `dt` ORBIT plugin)

`digitaltwin.service` exposes the framework as a long-running ORBIT
plugin: one session per client, many independent twins per session, and
twins that keep running while their client is away. Installing the
package registers the plugin through the `radical.orbit.plugins` entry
point, so a broker or endpoint only has to be told to host it.

```sh
pip install .[service]

# 1 - the broker, hosting the dt plugin
radical-orbit-broker.py --plugins default,dt

# 2 - a rhapsody endpoint: where the twins' tasks execute. The notify
# window costs 250 ms on every sequential prediction at its default
radical-orbit-endpoint.py -n dt_task_ep
# ... started with:
# RADICAL_ORBIT_RHAPSODY_NOTIFY_WINDOW=0
# RADICAL_ORBIT_RHAPSODY_BACKEND=concurrent
```

The client:

```python
from radical.orbit import EndpointRuntime
from digitaltwin.components import NULL_DTYPE, TRUTHY
from digitaltwin.service import register_user_modules

import my_components # not installed on the service
register_user_modules([my_components])

rt = EndpointRuntime()
rt.start(wait=True)

# 'broker' is the participant hosting dt; engine wiring is explicit
dt = rt.get_plugin('broker', 'dt', config={
'engines': {'task': {'endpoint_name': 'dt_task_ep',
'backends': ['concurrent']}}})

twin = dt.create_twin() # polls until the twin is ready
dt.add_task(twin, dt.package(MySensor), TRUTHY, SENSOR, is_persistent=True)
dt.add_investigator(twin, dt.package(MyModel), SENSOR, PREDICTION)
dt.start(twin)

print(dt.twin_list()) # the observation mechanism
answer = dt.get_inference(twin, TypedData(SENSOR, 5), PREDICTION)

dt.twin_close(twin)
```

The session outlives the client: reattach with
`rt.get_plugin('broker', 'dt', sid=<sid>)` and the twins are still
there. `dt.admin_sessions()` lists every session, twin, state and last
error on the service -- which is how orphans are found and torn down.
`test/09-service/` is a complete worked example.

Three contract notes:

- The client and the service must run **the same `digitaltwin` version**
(and compatible Python / cloudpickle): shipped component classes
pickle the framework by reference. Every call carries those versions
and the service rejects skew with a clear error rather than failing
somewhere inside an unpickle.
- A task's *arguments* are cloudpickled, but its **return value must be
JSON-safe or `bytes`** -- ORBIT's rhapsody plugin JSON-encodes results
and stringifies anything else. Return plain values from
`@flow.function_task` bodies and wrap them in `TypedData` in the
component.
- Persistent components run inline on the service's event loop. Their
bodies must be thin async glue publishing through
`runtime.stream`, never `@flow.function_task`s (the service warns when
it sees one).

### Binding policy for the service (R7)

The plugin runs its own DT stream broker, embedded, one per plugin and
shared by every twin. **It binds to loopback on a random port by
default, and that default is the safe one**: its payloads are
cloudpickled, so anyone who can reach the XSUB/XPUB ports gets code
execution in every subscriber -- weaker than the token-authenticated
ORBIT channel around it.

A non-loopback bind is possible (`DT_STREAM_PUB_ADDR` /
`DT_STREAM_SUB_ADDR` on the service host) but requires a deliberate
decision *and* a firewalled or private network. Until the data plane
moves inside ORBIT's authenticated channel, do not expose those ports --
including in demos.
Loading