Skip to content

Commit 3dcee4d

Browse files
committed
refactor(relay): use public Rust plugin SDK
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
1 parent d550a30 commit 3dcee4d

8 files changed

Lines changed: 346 additions & 795 deletions

File tree

Cargo.lock

Lines changed: 54 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ repository = "https://github.com/NVIDIA-NeMo/Switchyard"
2424
rust-version = "1.96.1"
2525

2626
[workspace.dependencies]
27+
async-channel = "2"
2728
async-stream = "0.3"
2829
async-trait = "0.1"
2930
futures = "0.3"

crates/switchyard-nemo-relay-plugin/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ publish = false
1616
crate-type = ["cdylib"]
1717

1818
[dependencies]
19+
async-channel.workspace = true
1920
async-trait.workspace = true
2021
futures-util.workspace = true
2122
http = "1"

crates/switchyard-nemo-relay-plugin/README.md

Lines changed: 82 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ SPDX-License-Identifier: Apache-2.0
88
This crate builds the external `nvidia.switchyard` native plugin. It embeds
99
`switchyard-libsy`, drives `Algorithm::run_stream`, and uses
1010
`switchyard-llm-client` for provider HTTP calls. Managed calls use Relay's
11-
generic asynchronous middleware hooks and do not require a targeted provider
11+
public Rust LLM execution-intercept SDK and do not require a targeted provider
1212
continuation from Relay.
1313

1414
The plugin uses NeMo Relay native API v1. It depends on the small
@@ -59,47 +59,87 @@ This boundary has two important consequences:
5959
transport activity is therefore not represented as nested Relay LLM
6060
lifecycle events. Relay records the outer managed call and the plugin emits
6161
Switchyard routing marks; bridging Switchyard transport spans into Relay is
62-
future work. With an exported Agent or turn scope, Relay 0.7 projects the
63-
outer LLM span and routing-decision span as siblings in one OpenInference
64-
trace. The native callback cannot parent an asynchronous decision mark
65-
directly under the active LLM event; an embedded host that invokes an LLM
66-
without an exported parent scope would therefore produce an orphan decision
67-
span.
62+
future work. Routing marks are delivered through the public SDK while Relay
63+
is polling the active execution callback or stream, so they remain attached
64+
to the current Relay scope stack.
6865
- Switchyard owns provider URLs, credentials, HTTP retry behavior, and
6966
translation for managed calls. Relay neither validates nor transports those
7067
target details.
7168

72-
## Native API v1 and asynchronous execution
73-
74-
The manifest remains `compat.native_api = "1"`, but this rebuilt plugin requires
75-
the generic C host-table v3 extension shipped by Relay 0.7. It registers through
76-
v3's completion-based buffered and incremental streaming hooks, returns
77-
`Pending` immediately, and performs libsy and HTTP work on a plugin-owned Tokio
78-
runtime. Relay's runtime workers therefore do not wait synchronously for
79-
provider I/O.
80-
81-
The v3 stream hook retains Relay's bounded 32-event output queue. The plugin
82-
retries a logical event when that queue is full, checks cancellation between
83-
attempts, and releases every completion, continuation, stream, and captured
84-
scope handle exactly once. Managed HTTP work is selected against Relay caller
85-
cancellation, so dropping a buffered or streaming call drops its in-flight
86-
request future.
87-
88-
Unmanaged profiles use the same generic v3 continuation hooks for pass-through.
89-
V3's downstream stream callback has continue/cancel control but no asynchronous
90-
acknowledgement, so it cannot provide true end-to-end backpressure. The adapter
91-
uses a nonblocking bridge capped at 8 MiB of queued, encoded event payloads; it
92-
also caps the queue at 256 events and safely rejects a pass-through stream that
93-
outruns either bound. The byte cap does not include transient JSON parsing or
94-
in-memory representation overhead. Direct host forwarding without this bound would
95-
still require a small generic Relay hook, but not a targeted-provider contract.
96-
Managed Switchyard streams do not use this pass-through bridge. None of the
97-
managed HTTP or routing operations requires a targeted LLM continuation.
98-
99-
This is still a raw C boundary: Switchyard contains a small ownership adapter
100-
for host strings and v3 handles because Relay 0.7 does not expose a safe Rust
101-
facade for the generic async surface. That adapter is transport-independent;
102-
all HTTP and routing behavior remains in Switchyard.
69+
## Native API v1 and the public Rust SDK
70+
71+
The manifest uses `compat.native_api = "1"`. The implementation registers with
72+
`PluginContext::register_llm_execution_intercept` and
73+
`PluginContext::register_llm_stream_execution_intercept`, receives typed
74+
`LlmRequest`, `LlmNext`, and `LlmStreamNext` values, and returns the SDK's JSON
75+
result or `LlmJsonStream`. The `nemo-relay-plugin` SDK owns the C callback
76+
trampolines, host strings, continuation handles, panic containment, and native
77+
stream lifecycle. Switchyard contains no raw C callback or host-table adapter.
78+
79+
Switchyard performs libsy and provider I/O on a plugin-owned Tokio runtime. A
80+
buffered SDK callback waits for that executor to finish. A managed streaming
81+
callback returns a pull-based Rust iterator backed by a bounded 32-message
82+
channel; the async producer waits when the consumer is slow. Dropping that
83+
iterator closes the channel and aborts its in-flight routing task.
84+
85+
The public native API v1 callback shapes do not provide full in-flight caller
86+
cancellation. A buffered callback has no cancellation token, so a provider
87+
request already in progress continues until it responds or reaches the client
88+
timeout after the caller disconnects. Relay can cancel a streaming iterator
89+
between pulls, but its synchronous `Iterator::next` call cannot be interrupted
90+
while it is waiting for the next provider event. The configured 120-second
91+
inactivity timeout bounds both cases. Supporting prompt disconnect propagation
92+
would require an asynchronous public SDK callback or stream-polling contract;
93+
the plugin does not bypass the SDK to recover that behavior.
94+
95+
### Relay runtime capacity
96+
97+
In Relay 0.7, the safe native LLM callbacks run directly in Relay's asynchronous
98+
middleware future; Relay does not move them to Tokio's separate blocking pool.
99+
Consequently, each active buffered Switchyard call occupies a normal Relay
100+
Tokio worker while it waits for the plugin executor, and a streaming call
101+
occupies a worker while its synchronous `Iterator::next` waits for the next
102+
provider event. Exhausting those workers can delay unrelated Relay work.
103+
104+
The Relay CLI constructs a Tokio multi-thread runtime without setting an
105+
explicit worker count. Tokio therefore defaults to the number of CPU cores
106+
available to the process and honors its `TOKIO_WORKER_THREADS` environment
107+
variable. Operators can provide additional capacity while using this native API
108+
v1 integration, for example:
109+
110+
```bash
111+
TOKIO_WORKER_THREADS=32 nemo-relay ...
112+
```
113+
114+
This adjusts the normal asynchronous worker pool, not Tokio's blocking pool;
115+
increasing `max_blocking_threads` does not address this integration. There is no
116+
universal recommended value. Size the pool with headroom above the expected
117+
number of concurrent managed calls and Relay's other work, then validate it
118+
under representative provider latency and streaming concurrency. Increasing
119+
the worker count mitigates starvation but does not restore cancellation or make
120+
the synchronous boundary non-blocking.
121+
122+
Embedded Relay hosts own their Tokio runtime and should configure the same
123+
capacity explicitly:
124+
125+
```rust
126+
let runtime = tokio::runtime::Builder::new_multi_thread()
127+
.worker_threads(32)
128+
.enable_all()
129+
.build()?;
130+
```
131+
132+
The durable resolution is a public Relay SDK callback and stream-polling
133+
contract that can yield while provider I/O is pending and receive caller
134+
cancellation. Until that exists, keep the provider timeout bounded, provision
135+
worker capacity, and load-test the intended concurrency rather than relying on
136+
thread-pool growth alone.
137+
138+
Unmanaged profiles call the typed `LlmNext` or `LlmStreamNext` continuation and
139+
return its result directly. Managed streams use the bounded Switchyard channel;
140+
unmanaged streams use Relay's SDK-owned pull iterator without an additional
141+
bridge. None of the managed HTTP or routing operations requires a targeted LLM
142+
continuation or direct access to Relay's C ABI.
103143

104144
## Supported routers
105145

@@ -144,11 +184,10 @@ preservation contract and an explicit reject-lossy stream policy.
144184
During release-candidate validation the manifest declares
145185
`compat.native_api = "1"` and Relay `>=0.7.0-rc.4,<1.0`, and the Rust SDK uses
146186
the exact published `0.7.0-rc.4` crate. Before release, move both lower bounds
147-
to stable `0.7.0`. The manifest API value selects the released v1 plugin
148-
contract; the binary is built against the V3 C host table shipped on the Relay
149-
0.7 line, which is why the minimum Relay version is not 0.6. Rebuild the bundle
150-
when changing SDK versions rather than assuming Rust dynamic-library
151-
compatibility from the manifest value alone.
187+
to stable `0.7.0`. The manifest API value selects Relay's released native
188+
plugin contract; plugin authors use its safe Rust SDK rather than the underlying
189+
C table directly. Rebuild the bundle when changing SDK versions rather than
190+
assuming Rust dynamic-library compatibility from the manifest value alone.
152191

153192
A Relay project can configure a seeded weighted-random router as follows:
154193

crates/switchyard-nemo-relay-plugin/src/executor.rs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ use tokio::task::AbortHandle;
1111

1212
/// Plugin-owned async executor.
1313
///
14-
/// Relay's generic V3 host table lets the plugin return `Pending`, so neither
15-
/// buffered nor streaming callbacks block Relay runtime workers. Keeping the
16-
/// runtime on a dedicated thread also avoids entering Relay's Tokio runtime
17-
/// from a separately linked cdylib.
14+
/// The public native-plugin SDK uses synchronous Rust callbacks and pull-based
15+
/// iterators at the dynamic-library boundary. Switchyard performs provider I/O
16+
/// on this dedicated runtime rather than entering Relay's Tokio runtime from a
17+
/// separately linked cdylib.
1818
#[derive(Clone)]
1919
pub(crate) struct PluginExecutor {
2020
inner: Arc<ExecutorInner>,
@@ -74,6 +74,20 @@ impl PluginExecutor {
7474
{
7575
self.inner.handle.spawn(future).abort_handle()
7676
}
77+
78+
pub(crate) fn run<F>(&self, future: F) -> Result<F::Output, String>
79+
where
80+
F: Future + Send + 'static,
81+
F::Output: Send + 'static,
82+
{
83+
let (sender, receiver) = mpsc::sync_channel(1);
84+
self.inner.handle.spawn(async move {
85+
let _ = sender.send(future.await);
86+
});
87+
receiver
88+
.recv()
89+
.map_err(|_| "Switchyard HTTP runtime stopped before completing work".to_string())
90+
}
7791
}
7892

7993
impl Drop for ExecutorInner {
@@ -113,6 +127,7 @@ mod tests {
113127
#[test]
114128
fn executor_runs_buffered_and_spawned_work() {
115129
let executor = PluginExecutor::new().unwrap();
130+
assert_eq!(executor.run(async { 42 }).unwrap(), 42);
116131
let (sender, receiver) = mpsc::sync_channel(1);
117132
executor.spawn(async move {
118133
sender.send("done").unwrap();

0 commit comments

Comments
 (0)