Skip to main content

greentic_runner_host/runner/
remote_dispatch.rs

1//! Generic async dispatch of flow work to a separate runtime over NATS pub/sub.
2//! The `RemoteDispatchHandler` trait isolates the transport so it can be swapped
3//! (e.g. to greentic-events EventBus) without touching the flow engine.
4
5use anyhow::Result;
6use async_nats::HeaderMap;
7use async_trait::async_trait;
8use greentic_types::{
9    DispatchError, DispatchMode, RuntimeDispatchRequest, RuntimeDispatchResponse, request_topic,
10    response_topic,
11};
12use serde_json::Value;
13
14/// Everything needed to dispatch one unit of work to a runtime.
15pub struct RemoteDispatch {
16    pub tenant: String,
17    pub env: String,
18    pub runtime: String,
19    pub target: String,
20    pub operation: String,
21    pub mode: DispatchMode,
22    /// Opaque correlation id (= canonical session hint); echoed by the response.
23    pub correlation_id: String,
24    pub input: Value,
25    pub deadline_ms: Option<u64>,
26}
27
28/// Outcome of a dispatch: the flow waits for a response, or it was fire-and-forget.
29#[derive(Debug)]
30pub enum RemoteDispatchAction {
31    AwaitingResponse { correlation_id: String },
32    Dispatched,
33}
34
35/// A built NATS message (broker-free; unit-testable).
36pub struct BuiltRequest {
37    pub subject: String,
38    pub headers: HeaderMap,
39    pub body: Vec<u8>,
40    pub action: RemoteDispatchAction,
41}
42
43/// Pure builder: turn a [`RemoteDispatch`] into the subject/headers/body to
44/// publish plus the resulting action. No I/O — unit-testable without a broker.
45pub fn build_request(req: &RemoteDispatch) -> Result<BuiltRequest> {
46    let body_struct = RuntimeDispatchRequest {
47        target: req.target.clone(),
48        operation: req.operation.clone(),
49        mode: req.mode,
50        input: req.input.clone(),
51        deadline_ms: req.deadline_ms,
52    };
53    let body = serde_json::to_vec(&body_struct)?;
54
55    let mut headers = HeaderMap::new();
56    headers.insert("Greentic-Correlation-Id", req.correlation_id.as_str());
57    headers.insert("Greentic-Tenant", req.tenant.as_str());
58    headers.insert("Greentic-Env", req.env.as_str());
59    headers.insert("Greentic-Idempotency-Key", req.correlation_id.as_str());
60
61    let action = match req.mode {
62        DispatchMode::Await => RemoteDispatchAction::AwaitingResponse {
63            correlation_id: req.correlation_id.clone(),
64        },
65        DispatchMode::FireAndForget => RemoteDispatchAction::Dispatched,
66    };
67
68    Ok(BuiltRequest {
69        subject: request_topic(&req.runtime),
70        headers,
71        body,
72        action,
73    })
74}
75
76/// Build the subject, headers, and serialized body for a timeout error response.
77///
78/// This is a pure function (no I/O) that constructs the message a spawned
79/// timeout task should publish when `deadline_ms` elapses before the real
80/// runtime responds.
81///
82/// # Parameters
83/// - `runtime` - the runtime name (e.g. `"sorla"`)
84/// - `correlation_id` - echoed verbatim in the `Greentic-Correlation-Id` header
85/// - `tenant` - echoed in `Greentic-Tenant`
86/// - `env` - echoed in `Greentic-Env`
87/// - `deadline_ms` - the timeout that expired; used in the error message
88///
89/// # Returns
90/// A tuple of `(subject, headers, body_bytes)` ready to pass to
91/// `async_nats::Client::publish_with_headers`.
92pub fn build_timeout_response_message(
93    runtime: &str,
94    correlation_id: &str,
95    tenant: &str,
96    env: &str,
97    deadline_ms: u64,
98) -> (String, HeaderMap, Vec<u8>) {
99    let subject = response_topic(runtime);
100
101    let mut headers = HeaderMap::new();
102    headers.insert("Greentic-Correlation-Id", correlation_id);
103    headers.insert("Greentic-Tenant", tenant);
104    headers.insert("Greentic-Env", env);
105
106    let response = RuntimeDispatchResponse {
107        ok: false,
108        output: Value::Null,
109        events: vec![],
110        error: Some(DispatchError {
111            code: "timeout".into(),
112            message: format!("no response within {deadline_ms}ms"),
113        }),
114    };
115    // Serialization of well-formed types is infallible in practice.
116    let body = serde_json::to_vec(&response).unwrap_or_default();
117
118    (subject, headers, body)
119}
120
121/// Transport-agnostic publisher for runtime dispatch requests. Implemented over
122/// raw `async-nats` today; the trait lets a greentic-events EventBus be swapped
123/// in later without changing the flow engine.
124#[async_trait]
125pub trait RemoteDispatchHandler: Send + Sync {
126    async fn dispatch(&self, request: RemoteDispatch) -> Result<RemoteDispatchAction>;
127}
128
129/// Publishes dispatch requests to NATS via raw `async-nats`.
130pub struct NatsDispatcher {
131    client: async_nats::Client,
132}
133
134impl NatsDispatcher {
135    pub fn new(client: async_nats::Client) -> Self {
136        Self { client }
137    }
138}
139
140#[async_trait]
141impl RemoteDispatchHandler for NatsDispatcher {
142    async fn dispatch(&self, request: RemoteDispatch) -> Result<RemoteDispatchAction> {
143        // Capture fields needed by the timeout task BEFORE moving `request`
144        // into `build_request`.
145        let maybe_timeout = match (request.mode, request.deadline_ms) {
146            (DispatchMode::Await, Some(deadline_ms)) => Some((
147                deadline_ms,
148                request.runtime.clone(),
149                request.correlation_id.clone(),
150                request.tenant.clone(),
151                request.env.clone(),
152            )),
153            _ => None,
154        };
155
156        let built = build_request(&request)?;
157        self.client
158            .publish_with_headers(built.subject, built.headers, built.body.into())
159            .await?;
160
161        // Spawn the deadline watchdog AFTER the request is on the wire.
162        if let Some((deadline_ms, runtime, correlation_id, tenant, env)) = maybe_timeout {
163            let timeout_client = self.client.clone();
164            tokio::spawn(async move {
165                tokio::time::sleep(tokio::time::Duration::from_millis(deadline_ms)).await;
166                let (subject, headers, body) = build_timeout_response_message(
167                    &runtime,
168                    &correlation_id,
169                    &tenant,
170                    &env,
171                    deadline_ms,
172                );
173                if let Err(publish_error) = timeout_client
174                    .publish_with_headers(subject, headers, body.into())
175                    .await
176                {
177                    tracing::warn!(
178                        %publish_error,
179                        %correlation_id,
180                        deadline_ms,
181                        "failed to publish timeout response for awaited dispatch"
182                    );
183                }
184            });
185        }
186
187        Ok(built.action)
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use greentic_types::{DispatchMode, RuntimeDispatchRequest, RuntimeDispatchResponse};
195    use serde_json::json;
196
197    fn sample(mode: DispatchMode) -> RemoteDispatch {
198        RemoteDispatch {
199            tenant: "t1".into(),
200            env: "default".into(),
201            runtime: "sorla".into(),
202            target: "dep-1".into(),
203            operation: "create".into(),
204            mode,
205            correlation_id: "t1:web:chan:conv:user::pack=p".into(), // opaque hint, contains ':'
206            input: json!({"a": 1}),
207            deadline_ms: Some(1000),
208        }
209    }
210
211    #[test]
212    fn build_request_targets_request_topic_with_headers_and_body() {
213        let built = build_request(&sample(DispatchMode::Await)).unwrap();
214        assert_eq!(built.subject, "greentic.sorla.request.v1");
215        assert_eq!(
216            built
217                .headers
218                .get("Greentic-Correlation-Id")
219                .map(|v| v.as_str()),
220            Some("t1:web:chan:conv:user::pack=p")
221        );
222        assert_eq!(
223            built.headers.get("Greentic-Tenant").map(|v| v.as_str()),
224            Some("t1")
225        );
226        let body: RuntimeDispatchRequest = serde_json::from_slice(&built.body).unwrap();
227        assert_eq!(body.operation, "create");
228        assert_eq!(body.mode, DispatchMode::Await);
229        assert!(matches!(
230            built.action,
231            RemoteDispatchAction::AwaitingResponse { .. }
232        ));
233    }
234
235    #[test]
236    fn fire_and_forget_action_is_dispatched() {
237        let built = build_request(&sample(DispatchMode::FireAndForget)).unwrap();
238        assert!(matches!(built.action, RemoteDispatchAction::Dispatched));
239    }
240
241    #[test]
242    fn build_request_targets_telco_x_request_topic() {
243        // `telco-x.call` uses runtime name "telco-x" → subject
244        // `greentic.telco-x.request.v1` (the runner now also registers a
245        // matching response listener for this runtime).
246        let mut dispatch = sample(DispatchMode::Await);
247        dispatch.runtime = "telco-x".into();
248        let built = build_request(&dispatch).unwrap();
249        assert_eq!(built.subject, "greentic.telco-x.request.v1");
250    }
251
252    #[test]
253    fn build_timeout_response_message_telco_x_response_topic() {
254        let (subject, _headers, _body) =
255            build_timeout_response_message("telco-x", "corr-1", "t1", "default", 500);
256        assert_eq!(subject, "greentic.telco-x.response.v1");
257    }
258
259    #[test]
260    fn build_timeout_response_message_uses_response_topic() {
261        let (subject, _headers, _body) =
262            build_timeout_response_message("sorla", "corr-1", "t1", "default", 500);
263        assert_eq!(subject, "greentic.sorla.response.v1");
264    }
265
266    #[test]
267    fn build_timeout_response_message_echoes_correlation_header() {
268        let (_subject, headers, _body) =
269            build_timeout_response_message("sorla", "my-corr-id", "t1", "default", 1000);
270        assert_eq!(
271            headers.get("Greentic-Correlation-Id").map(|v| v.as_str()),
272            Some("my-corr-id")
273        );
274    }
275
276    #[test]
277    fn build_timeout_response_message_body_deserializes_to_timeout_error() {
278        let (_subject, _headers, body) =
279            build_timeout_response_message("sorla", "corr-1", "t1", "default", 200);
280        let response: RuntimeDispatchResponse = serde_json::from_slice(&body).unwrap();
281        assert!(!response.ok, "ok must be false");
282        assert_eq!(response.output, serde_json::Value::Null);
283        let error = response.error.expect("error must be present");
284        assert_eq!(error.code, "timeout");
285        assert!(
286            error.message.contains("200ms"),
287            "message must mention deadline: {:?}",
288            error.message
289        );
290    }
291}