Skip to main content

dynamo_runtime/
engine.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Asynchronous Engine System with Type Erasure Support
5//!
6//! This module provides the core asynchronous engine abstraction for Dynamo's runtime system.
7//! It defines the `AsyncEngine` trait for streaming engines and provides sophisticated
8//! type-erasure capabilities for managing heterogeneous engine collections.
9//!
10//! ## Type Erasure Overview
11//!
12//! Type erasure is a critical feature that allows storing different `AsyncEngine` implementations
13//! with varying generic type parameters in a single collection (e.g., `HashMap<String, Arc<dyn AnyAsyncEngine>>`).
14//! This is essential for:
15//!
16//! - **Dynamic Engine Management**: Registering and retrieving engines at runtime based on configuration
17//! - **Plugin Systems**: Loading different engine implementations without compile-time knowledge
18//! - **Service Discovery**: Managing multiple engine types in a unified registry
19//!
20//! ## Implementation Details
21//!
22//! The type-erasure system uses several advanced Rust features:
23//!
24//! - **Trait Objects (`dyn Trait`)**: For runtime polymorphism without compile-time type information
25//! - **`std::any::TypeId`**: For runtime type checking during downcasting
26//! - **`std::any::Any`**: For type-erased storage and safe downcasting
27//! - **`PhantomData`**: For maintaining type relationships in generic wrappers
28//! - **Extension Traits**: For ergonomic API design without modifying existing types
29//!
30//! ## Safety Considerations
31//!
32//! ⚠️ **IMPORTANT**: The type-erasure system relies on precise type matching at runtime.
33//! When modifying these traits or their implementations:
34//!
35//! - **Never change the type ID logic** in `AnyAsyncEngine` implementations
36//! - **Maintain the blanket `Data` implementation** for all `Send + Sync + 'static` types
37//! - **Test downcasting thoroughly** when adding new engine types
38//! - **Document any changes** that affect the type-erasure behavior
39//!
40//! ## Usage Example
41//!
42//! ```rust,ignore
43//! use std::collections::HashMap;
44//! use std::sync::Arc;
45//! use crate::engine::{AsyncEngine, AsAnyAsyncEngine, DowncastAnyAsyncEngine};
46//!
47//! // Create typed engines
48//! let string_engine: Arc<dyn AsyncEngine<String, String, ()>> = Arc::new(MyStringEngine::new());
49//! let int_engine: Arc<dyn AsyncEngine<i32, i32, ()>> = Arc::new(MyIntEngine::new());
50//!
51//! // Store in heterogeneous collection
52//! let mut engines: HashMap<String, Arc<dyn AnyAsyncEngine>> = HashMap::new();
53//! engines.insert("string".to_string(), string_engine.into_any_engine());
54//! engines.insert("int".to_string(), int_engine.into_any_engine());
55//!
56//! // Retrieve and downcast safely
57//! if let Some(typed_engine) = engines.get("string").unwrap().downcast::<String, String, ()>() {
58//!     let result = typed_engine.generate("hello".to_string()).await;
59//! }
60//! ```
61
62use std::{
63    any::{Any, TypeId},
64    fmt::Debug,
65    future::Future,
66    marker::PhantomData,
67    pin::Pin,
68    sync::Arc,
69};
70
71pub use async_trait::async_trait;
72use futures::stream::Stream;
73
74/// All [`Send`] + [`Sync`] + `'static` types can be used as [`AsyncEngine`] request and response types.
75///
76/// This is implemented as a blanket implementation for all types that meet the bounds.
77/// **Do not manually implement this trait** - the blanket implementation covers all valid types.
78pub trait Data: Send + Sync + 'static {}
79impl<T: Send + Sync + 'static> Data for T {}
80
81/// [`DataStream`] is a type alias for a stream of [`Data`] items. This can be adapted to a [`ResponseStream`]
82/// by associating it with a [`AsyncEngineContext`].
83pub type DataUnary<T> = Pin<Box<dyn Future<Output = T> + Send>>;
84pub type DataStream<T> = Pin<Box<dyn Stream<Item = T> + Send>>;
85
86pub type Engine<Req, Resp, E> = Arc<dyn AsyncEngine<Req, Resp, E>>;
87pub type EngineUnary<Resp> = Pin<Box<dyn AsyncEngineUnary<Resp>>>;
88/// Trait-object alias for an [`AsyncEngineStream`] — used on both sides of an
89/// engine: the input side via [`crate::pipeline::ManyIn`] and the output side via
90/// [`crate::pipeline::ManyOut`]. The directional names exist
91/// at the [`crate::pipeline`] alias layer for documentary clarity at use sites.
92pub type EngineStream<T> = Pin<Box<dyn AsyncEngineStream<T>>>;
93pub type Context = Arc<dyn AsyncEngineContext>;
94pub type EngineContextGuard = Arc<dyn Any + Send + Sync>;
95
96impl<T: Data> From<EngineStream<T>> for DataStream<T> {
97    fn from(stream: EngineStream<T>) -> Self {
98        Box::pin(stream)
99    }
100}
101
102// The Controller and the Context when https://github.com/rust-lang/rust/issues/65991 becomes stable
103pub trait AsyncEngineController: Send + Sync {}
104
105/// The [`AsyncEngineContext`] trait defines the interface to control the resulting stream
106/// produced by the engine.
107///
108/// This trait provides lifecycle management for async operations, including:
109/// - Stream identification via unique IDs
110/// - Graceful shutdown capabilities (`stop_generating`)
111/// - Immediate termination capabilities (`kill`)
112/// - Status checking for stopped/killed states
113///
114/// Implementations should ensure thread-safety and proper state management
115/// across concurrent access patterns.
116#[async_trait]
117pub trait AsyncEngineContext: Send + Sync + Debug {
118    /// Unique ID for the Stream
119    fn id(&self) -> &str;
120
121    /// Returns true if `stop_generating()` has been called; otherwise, false.
122    fn is_stopped(&self) -> bool;
123
124    /// Returns true if `kill()` has been called; otherwise, false.
125    /// This can be used with a `.take_while()` stream combinator to immediately terminate
126    /// the stream.
127    ///
128    /// An ideal location for a `[.take_while(!ctx.is_killed())]` stream combinator is on
129    /// the most downstream  return stream.
130    fn is_killed(&self) -> bool;
131
132    /// Calling this method when [`AsyncEngineContext::is_stopped`] is `true` will return
133    /// immediately; otherwise, it will [`AsyncEngineContext::is_stopped`] will return true.
134    async fn stopped(&self);
135
136    /// Calling this method when [`AsyncEngineContext::is_killed`] is `true` will return
137    /// immediately; otherwise, it will [`AsyncEngineContext::is_killed`] will return true.
138    async fn killed(&self);
139
140    // Controller
141
142    /// Informs the [`AsyncEngine`] to stop producing results for this particular stream.
143    /// This method is idempotent. This method does not invalidate results current in the
144    /// stream. It might take some time for the engine to stop producing results. The caller
145    /// can decided to drain the stream or drop the stream.
146    fn stop_generating(&self);
147
148    /// See [`AsyncEngineContext::stop_generating`].
149    fn stop(&self);
150
151    /// Extends the [`AsyncEngineContext::stop_generating`] also indicates a preference to
152    /// terminate without draining the remaining items in the stream. This is implementation
153    /// specific and may not be supported by all engines.
154    fn kill(&self);
155
156    /// Links child AsyncEngineContext to this AsyncEngineContext. If the `stop_generating`, `stop`
157    /// or `kill` on this AsyncEngineContext is called, the same method is called on all linked
158    /// child AsyncEngineContext, in the order they are linked, and then the method on this
159    /// AsyncEngineContext continues.
160    fn link_child(&self, child: Arc<dyn AsyncEngineContext>);
161
162    /// Retain request-scoped state until the engine context is dropped.
163    fn retain(&self, guard: EngineContextGuard) {
164        drop(guard);
165    }
166}
167
168/// Provides access to the [`AsyncEngineContext`] associated with an engine operation.
169///
170/// This trait is implemented by both unary and streaming engine results, allowing
171/// uniform access to context information regardless of the operation type.
172pub trait AsyncEngineContextProvider: Send + Debug {
173    fn context(&self) -> Arc<dyn AsyncEngineContext>;
174}
175
176/// A unary (single-response) asynchronous engine operation.
177///
178/// This trait combines `Future` semantics with context provider capabilities,
179/// representing a single async operation that produces one result.
180pub trait AsyncEngineUnary<Resp: Data>:
181    Future<Output = Resp> + AsyncEngineContextProvider + Send
182{
183}
184
185/// A streaming asynchronous engine operation.
186///
187/// This trait combines `Stream` semantics with context provider capabilities,
188/// representing a continuous async operation that produces multiple messages over time.
189/// The canonical boxed form is [`EngineStream<T>`] (= `crate::pipeline::ManyOut<T>`),
190/// the stream of response chunks an engine emits; [`ResponseStream`] is the
191/// canonical concrete implementor.
192pub trait AsyncEngineStream<T: Data>: Stream<Item = T> + AsyncEngineContextProvider + Send {}
193
194/// Engine is a trait that defines the interface for a streaming engine.
195/// The synchronous Engine version is does not need to be awaited.
196///
197/// This is the core trait for all async engine implementations. It provides:
198/// - Generic type parameters for request, response, and error types
199/// - Async generation capabilities with proper error handling
200/// - Thread-safe design with `Send + Sync` bounds
201///
202/// ## Type Parameters
203/// - `Req`: The request type — required to be `Send + 'static`. The `Sync`
204///   bound was removed from `Req` for convenience: forcing `Sync` on `Req`
205///   propagates a `+ Sync` constraint onto every type that flows in (in
206///   particular, every input-side trait-object alias), and no
207///   existing implementation of `AsyncEngine` relies on the `Sync` nature of
208///   the request. Revisit if a future implementation genuinely needs
209///   shared-reference access to a request value across threads.
210/// - `Resp`: The response type that implements `AsyncEngineContextProvider`
211/// - `E`: The error type that implements `Data`
212///
213/// ## Implementation Notes
214/// Implementations should ensure proper error handling and resource management.
215/// The `generate` method should be cancellable via the response's context provider.
216#[async_trait]
217pub trait AsyncEngine<Req: Send + 'static, Resp: AsyncEngineContextProvider, E: Data>:
218    Send + Sync
219{
220    /// Generate a stream of completion responses.
221    async fn generate(&self, request: Req) -> Result<Resp, E>;
222}
223
224/// Adapter for a [`DataStream`] to a [`ResponseStream`].
225///
226/// A common pattern is to consume the [`ResponseStream`] with standard stream combinators
227/// which produces a [`DataStream`] stream, then form a [`ResponseStream`] by propagating the
228/// original [`AsyncEngineContext`].
229pub struct ResponseStream<R: Data> {
230    stream: DataStream<R>,
231    ctx: Arc<dyn AsyncEngineContext>,
232}
233
234impl<R: Data> ResponseStream<R> {
235    pub fn new(stream: DataStream<R>, ctx: Arc<dyn AsyncEngineContext>) -> Pin<Box<Self>> {
236        Box::pin(Self { stream, ctx })
237    }
238}
239
240impl<R: Data> Stream for ResponseStream<R> {
241    type Item = R;
242
243    #[inline]
244    fn poll_next(
245        mut self: Pin<&mut Self>,
246        cx: &mut std::task::Context<'_>,
247    ) -> std::task::Poll<Option<Self::Item>> {
248        Pin::new(&mut self.stream).poll_next(cx)
249    }
250}
251
252impl<R: Data> AsyncEngineStream<R> for ResponseStream<R> {}
253
254impl<R: Data> AsyncEngineContextProvider for ResponseStream<R> {
255    fn context(&self) -> Arc<dyn AsyncEngineContext> {
256        self.ctx.clone()
257    }
258}
259
260impl<R: Data> Debug for ResponseStream<R> {
261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        f.debug_struct("ResponseStream")
263            // todo: add debug for stream - possibly propagate some information about what
264            // engine created the stream
265            // .field("stream", &self.stream)
266            .field("ctx", &self.ctx)
267            .finish()
268    }
269}
270
271impl<T: Data> AsyncEngineContextProvider for Pin<Box<dyn AsyncEngineUnary<T>>> {
272    fn context(&self) -> Arc<dyn AsyncEngineContext> {
273        AsyncEngineContextProvider::context(&**self)
274    }
275}
276
277impl<T: Data> AsyncEngineContextProvider for Pin<Box<dyn AsyncEngineStream<T>>> {
278    fn context(&self) -> Arc<dyn AsyncEngineContext> {
279        AsyncEngineContextProvider::context(&**self)
280    }
281}
282
283/// A type-erased `AsyncEngine`.
284///
285/// This trait enables storing heterogeneous `AsyncEngine` implementations in collections
286/// by erasing their specific generic type parameters. It provides runtime type information
287/// and safe downcasting capabilities.
288///
289/// ## Type Erasure Mechanism
290/// The trait uses `std::any::TypeId` to preserve type information at runtime, allowing
291/// safe downcasting back to the original `AsyncEngine<Req, Resp, E>` types.
292///
293/// ## Safety Guarantees
294/// - Type IDs are preserved exactly as they were during type erasure
295/// - Downcasting is only possible to the original type combination
296/// - Incorrect downcasts return `None` rather than panicking
297///
298/// ## Implementation Notes
299/// This trait is implemented by the internal `AnyEngineWrapper` struct. Users should
300/// not implement this trait directly - use the `AsAnyAsyncEngine` extension trait instead.
301pub trait AnyAsyncEngine: Send + Sync {
302    /// Returns the `TypeId` of the request type used by this engine.
303    fn request_type_id(&self) -> TypeId;
304
305    /// Returns the `TypeId` of the response type used by this engine.
306    fn response_type_id(&self) -> TypeId;
307
308    /// Returns the `TypeId` of the error type used by this engine.
309    fn error_type_id(&self) -> TypeId;
310
311    /// Provides access to the underlying engine as a `dyn Any` for downcasting.
312    fn as_any(&self) -> &dyn Any;
313}
314
315/// An internal wrapper to hold a typed `AsyncEngine` behind the `AnyAsyncEngine` trait object.
316///
317/// This struct uses `PhantomData<fn(Req, Resp, E)>` to maintain the type relationship
318/// without storing the types directly, enabling the type-erasure mechanism.
319///
320/// ## PhantomData Usage
321/// The `PhantomData<fn(Req, Resp, E)>` ensures that the compiler knows about the
322/// generic type parameters without requiring them to be `'static`, which would
323/// prevent storing non-static types in the engine.
324struct AnyEngineWrapper<Req, Resp, E>
325where
326    Req: Data,
327    Resp: Data + AsyncEngineContextProvider,
328    E: Data,
329{
330    engine: Arc<dyn AsyncEngine<Req, Resp, E>>,
331    _phantom: PhantomData<fn(Req, Resp, E)>,
332}
333
334impl<Req, Resp, E> AnyAsyncEngine for AnyEngineWrapper<Req, Resp, E>
335where
336    Req: Data,
337    Resp: Data + AsyncEngineContextProvider,
338    E: Data,
339{
340    fn request_type_id(&self) -> TypeId {
341        TypeId::of::<Req>()
342    }
343
344    fn response_type_id(&self) -> TypeId {
345        TypeId::of::<Resp>()
346    }
347
348    fn error_type_id(&self) -> TypeId {
349        TypeId::of::<E>()
350    }
351
352    fn as_any(&self) -> &dyn Any {
353        &self.engine
354    }
355}
356
357/// An extension trait that provides a convenient way to type-erase an `AsyncEngine`.
358///
359/// This trait provides the `.into_any_engine()` method on any `Arc<dyn AsyncEngine<...>>`,
360/// enabling ergonomic type erasure without explicit wrapper construction.
361///
362/// ## Usage
363/// ```rust,ignore
364/// use crate::engine::AsAnyAsyncEngine;
365///
366/// let typed_engine: Arc<dyn AsyncEngine<String, String, ()>> = Arc::new(MyEngine::new());
367/// let any_engine = typed_engine.into_any_engine();
368/// ```
369pub trait AsAnyAsyncEngine {
370    /// Converts a typed `AsyncEngine` into a type-erased `AnyAsyncEngine`.
371    fn into_any_engine(self) -> Arc<dyn AnyAsyncEngine>;
372}
373
374impl<Req, Resp, E> AsAnyAsyncEngine for Arc<dyn AsyncEngine<Req, Resp, E>>
375where
376    Req: Data,
377    Resp: Data + AsyncEngineContextProvider,
378    E: Data,
379{
380    fn into_any_engine(self) -> Arc<dyn AnyAsyncEngine> {
381        Arc::new(AnyEngineWrapper {
382            engine: self,
383            _phantom: PhantomData,
384        })
385    }
386}
387
388/// An extension trait that provides a convenient method to downcast an `AnyAsyncEngine`.
389///
390/// This trait provides the `.downcast<Req, Resp, E>()` method on `Arc<dyn AnyAsyncEngine>`,
391/// enabling safe downcasting back to the original typed engine.
392///
393/// ## Safety
394/// The downcast method performs runtime type checking using `TypeId` comparison.
395/// It will only succeed if the type parameters exactly match the original engine's types.
396///
397/// ## Usage
398/// ```rust,ignore
399/// use crate::engine::DowncastAnyAsyncEngine;
400///
401/// let any_engine: Arc<dyn AnyAsyncEngine> = // ... from collection
402/// if let Some(typed_engine) = any_engine.downcast::<String, String, ()>() {
403///     // Use the typed engine
404///     let result = typed_engine.generate("hello".to_string()).await;
405/// }
406/// ```
407pub trait DowncastAnyAsyncEngine {
408    /// Attempts to downcast an `AnyAsyncEngine` to a specific `AsyncEngine` type.
409    ///
410    /// Returns `Some(engine)` if the type parameters match the original engine,
411    /// or `None` if the types don't match.
412    fn downcast<Req, Resp, E>(&self) -> Option<Arc<dyn AsyncEngine<Req, Resp, E>>>
413    where
414        Req: Data,
415        Resp: Data + AsyncEngineContextProvider,
416        E: Data;
417}
418
419impl DowncastAnyAsyncEngine for Arc<dyn AnyAsyncEngine> {
420    fn downcast<Req, Resp, E>(&self) -> Option<Arc<dyn AsyncEngine<Req, Resp, E>>>
421    where
422        Req: Data,
423        Resp: Data + AsyncEngineContextProvider,
424        E: Data,
425    {
426        if self.request_type_id() == TypeId::of::<Req>()
427            && self.response_type_id() == TypeId::of::<Resp>()
428            && self.error_type_id() == TypeId::of::<E>()
429        {
430            self.as_any()
431                .downcast_ref::<Arc<dyn AsyncEngine<Req, Resp, E>>>()
432                .cloned()
433        } else {
434            None
435        }
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use std::collections::HashMap;
443
444    // 1. Define mock data structures
445    #[derive(Debug, PartialEq)]
446    struct Req1(String);
447
448    #[derive(Debug, PartialEq)]
449    struct Resp1(String);
450
451    // Dummy context provider implementation for the response
452    impl AsyncEngineContextProvider for Resp1 {
453        fn context(&self) -> Arc<dyn AsyncEngineContext> {
454            // For this test, we don't need a real context.
455            unimplemented!()
456        }
457    }
458
459    #[derive(Debug)]
460    struct Err1;
461
462    // A different set of types for testing failure cases
463    #[derive(Debug)]
464    struct Req2;
465    #[derive(Debug)]
466    struct Resp2;
467    impl AsyncEngineContextProvider for Resp2 {
468        fn context(&self) -> Arc<dyn AsyncEngineContext> {
469            unimplemented!()
470        }
471    }
472
473    // 2. Define a mock engine
474    struct MockEngine;
475
476    #[async_trait]
477    impl AsyncEngine<Req1, Resp1, Err1> for MockEngine {
478        async fn generate(&self, request: Req1) -> Result<Resp1, Err1> {
479            Ok(Resp1(format!("response to {}", request.0)))
480        }
481    }
482
483    #[tokio::test]
484    async fn test_engine_type_erasure_and_downcast() {
485        // 3. Create a typed engine
486        let typed_engine: Arc<dyn AsyncEngine<Req1, Resp1, Err1>> = Arc::new(MockEngine);
487
488        // 4. Use the extension trait to erase the type
489        let any_engine = typed_engine.into_any_engine();
490
491        // Check type IDs are preserved
492        assert_eq!(any_engine.request_type_id(), TypeId::of::<Req1>());
493        assert_eq!(any_engine.response_type_id(), TypeId::of::<Resp1>());
494        assert_eq!(any_engine.error_type_id(), TypeId::of::<Err1>());
495
496        // 5. Use the new downcast method on the Arc
497        let downcasted_engine = any_engine.downcast::<Req1, Resp1, Err1>();
498
499        // 6. Assert success
500        assert!(downcasted_engine.is_some());
501
502        // We can even use the downcasted engine
503        let response = downcasted_engine
504            .unwrap()
505            .generate(Req1("hello".to_string()))
506            .await;
507        assert_eq!(response.unwrap(), Resp1("response to hello".to_string()));
508
509        // 7. Assert failure for wrong types
510        let failed_downcast = any_engine.downcast::<Req2, Resp2, Err1>();
511        assert!(failed_downcast.is_none());
512
513        // 8. HashMap usage test
514        let mut engine_map: HashMap<String, Arc<dyn AnyAsyncEngine>> = HashMap::new();
515        engine_map.insert("mock".to_string(), any_engine);
516
517        let retrieved_engine = engine_map.get("mock").unwrap();
518        let final_engine = retrieved_engine.downcast::<Req1, Resp1, Err1>().unwrap();
519        let final_response = final_engine.generate(Req1("world".to_string())).await;
520        assert_eq!(
521            final_response.unwrap(),
522            Resp1("response to world".to_string())
523        );
524    }
525}