Skip to content

Commit 5fd58d7

Browse files
committed
fix(relay): restore async native middleware bridge
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
1 parent f0a80d5 commit 5fd58d7

5 files changed

Lines changed: 773 additions & 195 deletions

File tree

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

Lines changed: 35 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ 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-
public Rust LLM execution-intercept SDK and do not require a targeted provider
12-
continuation from Relay.
11+
completion-based asynchronous middleware hooks and do not require a targeted
12+
provider continuation from Relay.
1313

1414
The plugin uses NeMo Relay native API v1. It depends on the small
1515
`nemo-relay-plugin` authoring SDK, not the Relay runtime, and does not start
@@ -59,85 +59,39 @@ 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. 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.
62+
future work. The adapter captures the active Relay scope before returning
63+
`Pending`, so asynchronous routing marks retain their event parent.
6564
- Switchyard owns provider URLs, credentials, HTTP retry behavior, and
6665
translation for managed calls. Relay neither validates nor transports those
6766
target details.
6867

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. Supporting prompt disconnect
91-
propagation would require an asynchronous public SDK callback or stream-polling
92-
contract; the plugin does not bypass the SDK to recover that behavior.
93-
94-
### Relay runtime capacity
95-
96-
In Relay 0.7, the safe native LLM callbacks run directly in Relay's asynchronous
97-
middleware future; Relay does not move them to Tokio's separate blocking pool.
98-
Consequently, each active buffered Switchyard call occupies a normal Relay
99-
Tokio worker while it waits for the plugin executor, and a streaming call
100-
occupies a worker while its synchronous `Iterator::next` waits for the next
101-
provider event. Exhausting those workers can delay unrelated Relay work.
102-
103-
The Relay CLI constructs a Tokio multi-thread runtime without setting an
104-
explicit worker count. Tokio therefore defaults to the number of CPU cores
105-
available to the process and honors its `TOKIO_WORKER_THREADS` environment
106-
variable. Operators can provide additional capacity while using this native API
107-
v1 integration, for example:
108-
109-
```bash
110-
TOKIO_WORKER_THREADS=32 nemo-relay ...
111-
```
112-
113-
This adjusts the normal asynchronous worker pool, not Tokio's blocking pool;
114-
increasing `max_blocking_threads` does not address this integration. There is no
115-
universal recommended value. Size the pool with headroom above the expected
116-
number of concurrent managed calls and Relay's other work, then validate it
117-
under representative provider latency and streaming concurrency. Increasing
118-
the worker count mitigates starvation but does not restore cancellation or make
119-
the synchronous boundary non-blocking.
120-
121-
Embedded Relay hosts own their Tokio runtime and should configure the same
122-
capacity explicitly:
123-
124-
```rust
125-
let runtime = tokio::runtime::Builder::new_multi_thread()
126-
.worker_threads(32)
127-
.enable_all()
128-
.build()?;
129-
```
130-
131-
The durable resolution is a public Relay SDK callback and stream-polling
132-
contract that can yield while provider I/O is pending and receive caller
133-
cancellation. Until that exists, provision worker capacity and load-test the
134-
intended concurrency rather than relying on thread-pool growth alone.
135-
136-
Unmanaged profiles call the typed `LlmNext` or `LlmStreamNext` continuation and
137-
return its result directly. Managed streams use the bounded Switchyard channel;
138-
unmanaged streams use Relay's SDK-owned pull iterator without an additional
139-
bridge. None of the managed HTTP or routing operations requires a targeted LLM
140-
continuation or direct access to Relay's C ABI.
68+
## Native API v1 and asynchronous execution
69+
70+
The manifest remains `compat.native_api = "1"`, but the plugin requires the
71+
generic host-table v3 extension shipped by Relay 0.7. It registers through
72+
v3's completion-based buffered and incremental streaming hooks, returns
73+
`Pending` immediately, and performs libsy and provider HTTP work on a
74+
plugin-owned Tokio runtime. Relay workers therefore do not wait synchronously
75+
for provider I/O.
76+
77+
The stream adapter forwards the plugin's bounded 32-message channel into
78+
Relay's bounded output queue. It retries a logical event when the host queue is
79+
full and checks cancellation between attempts. Managed HTTP work is selected
80+
against Relay caller cancellation, so cancelling a buffered or streaming call
81+
drops its in-flight provider future.
82+
83+
Unmanaged profiles use the same v3 continuation hooks for pass-through. V3's
84+
downstream stream callback has continue/cancel control but no asynchronous
85+
acknowledgement, so the adapter uses a nonblocking bridge capped at 8 MiB of
86+
queued encoded payloads and 256 events. A pass-through stream that outruns
87+
either bound is rejected rather than consuming unbounded memory.
88+
89+
This is a raw C boundary: Switchyard contains a small ownership adapter for
90+
host strings, completion and stream handles, continuation handles, and captured
91+
scope handles because Relay 0.7 does not expose a safe Rust facade for its
92+
generic asynchronous surface. The HTTP, routing, and translation behavior
93+
remains in Switchyard. The adapter can be replaced with the safe typed surface
94+
when Relay exposes equivalent asynchronous callbacks and cancellation.
14195

14296
## Supported routers
14397

@@ -183,10 +137,10 @@ per-event preservation envelope.
183137

184138
The manifest declares `compat.native_api = "1"` and Relay `>=0.7.0,<0.8`, and
185139
the Rust SDK uses the exact published `0.7.0` crate. The manifest API value
186-
selects Relay's released native plugin contract; plugin authors use its safe
187-
Rust SDK rather than the underlying C table directly. Rebuild the bundle when
188-
changing SDK versions rather than assuming Rust dynamic-library compatibility
189-
from the manifest value alone.
140+
selects Relay's released native plugin contract; the binary also requires the
141+
v3 C host table shipped on the Relay 0.7 line. Rebuild the bundle when changing
142+
SDK versions rather than assuming Rust dynamic-library compatibility from the
143+
manifest value alone.
190144

191145
A Relay project can configure a seeded weighted-random router as follows:
192146

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

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -74,20 +74,6 @@ 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-
}
9177
}
9278

9379
impl Drop for ExecutorInner {
@@ -125,9 +111,8 @@ mod tests {
125111
use super::*;
126112

127113
#[test]
128-
fn executor_runs_buffered_and_spawned_work() {
114+
fn executor_spawns_work() {
129115
let executor = PluginExecutor::new().unwrap();
130-
assert_eq!(executor.run(async { 42 }).unwrap(), 42);
131116
let (sender, receiver) = mpsc::sync_channel(1);
132117
executor.spawn(async move {
133118
sender.send("done").unwrap();

0 commit comments

Comments
 (0)